; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-10-21 17:33:07 +09:00
commit 9cb705dba8
21 changed files with 2314 additions and 270 deletions

View File

@ -24,6 +24,8 @@ export class DashboardController {
): Promise<void> {
try {
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
if (!userId) {
res.status(401).json({
success: false,
@ -89,7 +91,8 @@ export class DashboardController {
const savedDashboard = await DashboardService.createDashboard(
dashboardData,
userId
userId,
companyCode
);
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
@ -121,6 +124,7 @@ export class DashboardController {
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
@ -145,7 +149,11 @@ export class DashboardController {
return;
}
const result = await DashboardService.getDashboards(query, userId);
const result = await DashboardService.getDashboards(
query,
userId,
companyCode
);
res.json({
success: true,
@ -173,6 +181,7 @@ export class DashboardController {
try {
const { id } = req.params;
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
if (!id) {
res.status(400).json({
@ -182,7 +191,11 @@ export class DashboardController {
return;
}
const dashboard = await DashboardService.getDashboardById(id, userId);
const dashboard = await DashboardService.getDashboardById(
id,
userId,
companyCode
);
if (!dashboard) {
res.status(404).json({
@ -393,6 +406,8 @@ export class DashboardController {
return;
}
const companyCode = req.user?.companyCode;
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
@ -401,7 +416,11 @@ export class DashboardController {
createdBy: userId, // 본인이 만든 대시보드만
};
const result = await DashboardService.getDashboards(query, userId);
const result = await DashboardService.getDashboards(
query,
userId,
companyCode
);
res.json({
success: true,

View File

@ -36,10 +36,18 @@ export const saveFormData = async (
formDataWithMeta.company_code = companyCode;
}
// 클라이언트 IP 주소 추출
const ipAddress =
req.ip ||
(req.headers["x-forwarded-for"] as string) ||
req.socket.remoteAddress ||
"unknown";
const result = await dynamicFormService.saveFormData(
screenId,
tableName,
formDataWithMeta
formDataWithMeta,
ipAddress
);
res.json({

View File

@ -1048,3 +1048,268 @@ export async function updateColumnWebType(
res.status(500).json(response);
}
}
// ========================================
// 🎯 테이블 로그 시스템 API
// ========================================
/**
*
*/
export async function createLogTable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { pkColumn } = req.body;
const userId = req.user?.userId;
logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) {
const response: ApiResponse<null> = {
success: false,
message: "PK 컬럼 정보가 필요합니다.",
error: {
code: "MISSING_PK_COLUMN",
details: "PK 컬럼명과 데이터 타입이 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.createLogTable(tableName, pkColumn, userId);
logger.info(`로그 테이블 생성 완료: ${tableName}_log`);
const response: ApiResponse<null> = {
success: true,
message: "로그 테이블이 성공적으로 생성되었습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 테이블 생성 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 테이블 생성 중 오류가 발생했습니다.",
error: {
code: "LOG_TABLE_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function getLogConfig(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 로그 설정 조회: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const logConfig = await tableManagementService.getLogConfig(tableName);
const response: ApiResponse<typeof logConfig> = {
success: true,
message: "로그 설정을 조회했습니다.",
data: logConfig,
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 설정 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 설정 조회 중 오류가 발생했습니다.",
error: {
code: "LOG_CONFIG_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function getLogData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const {
page = 1,
size = 20,
operationType,
startDate,
endDate,
changedBy,
originalId,
} = req.query;
logger.info(`=== 로그 데이터 조회: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const result = await tableManagementService.getLogData(tableName, {
page: parseInt(page as string),
size: parseInt(size as string),
operationType: operationType as string,
startDate: startDate as string,
endDate: endDate as string,
changedBy: changedBy as string,
originalId: originalId as string,
});
logger.info(
`로그 데이터 조회 완료: ${tableName}_log, ${result.total}`
);
const response: ApiResponse<typeof result> = {
success: true,
message: "로그 데이터를 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "LOG_DATA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* /
*/
export async function toggleLogTable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { isActive } = req.body;
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (isActive === undefined || isActive === null) {
const response: ApiResponse<null> = {
success: false,
message: "isActive 값이 필요합니다.",
error: {
code: "MISSING_IS_ACTIVE",
details: "isActive 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.toggleLogTable(
tableName,
isActive === "Y" || isActive === true
);
logger.info(
`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`
);
const response: ApiResponse<null> = {
success: true,
message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`,
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 테이블 토글 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 테이블 토글 중 오류가 발생했습니다.",
error: {
code: "LOG_TOGGLE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -18,6 +18,10 @@ import {
checkTableExists,
getColumnWebTypes,
checkDatabaseConnection,
createLogTable,
getLogConfig,
getLogData,
toggleLogTable,
} from "../controllers/tableManagementController";
const router = express.Router();
@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData);
*/
router.delete("/tables/:tableName/delete", deleteTableData);
// ========================================
// 테이블 로그 시스템 API
// ========================================
/**
*
* POST /api/table-management/tables/:tableName/log
*/
router.post("/tables/:tableName/log", createLogTable);
/**
*
* GET /api/table-management/tables/:tableName/log/config
*/
router.get("/tables/:tableName/log/config", getLogConfig);
/**
*
* GET /api/table-management/tables/:tableName/log
*/
router.get("/tables/:tableName/log", getLogData);
/**
* /
* POST /api/table-management/tables/:tableName/log/toggle
*/
router.post("/tables/:tableName/log/toggle", toggleLogTable);
export default router;

View File

@ -18,7 +18,8 @@ export class DashboardService {
*/
static async createDashboard(
data: CreateDashboardRequest,
userId: string
userId: string,
companyCode?: string
): Promise<Dashboard> {
const dashboardId = uuidv4();
const now = new Date();
@ -31,8 +32,8 @@ export class DashboardService {
`
INSERT INTO dashboards (
id, title, description, is_public, created_by,
created_at, updated_at, tags, category, view_count, settings
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
created_at, updated_at, tags, category, view_count, settings, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`,
[
dashboardId,
@ -46,6 +47,7 @@ export class DashboardService {
data.category || null,
0,
JSON.stringify(data.settings || {}),
companyCode || "DEFAULT",
]
);
@ -143,7 +145,11 @@ export class DashboardService {
/**
*
*/
static async getDashboards(query: DashboardListQuery, userId?: string) {
static async getDashboards(
query: DashboardListQuery,
userId?: string,
companyCode?: string
) {
const {
page = 1,
limit = 20,
@ -161,6 +167,13 @@ export class DashboardService {
let params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (최우선)
if (companyCode) {
whereConditions.push(`d.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 권한 필터링
if (userId) {
whereConditions.push(
@ -278,7 +291,8 @@ export class DashboardService {
*/
static async getDashboardById(
dashboardId: string,
userId?: string
userId?: string,
companyCode?: string
): Promise<Dashboard | null> {
try {
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
@ -286,21 +300,43 @@ export class DashboardService {
let dashboardParams: any[];
if (userId) {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND (d.created_by = $2 OR d.is_public = true)
`;
dashboardParams = [dashboardId, userId];
if (companyCode) {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.company_code = $2
AND (d.created_by = $3 OR d.is_public = true)
`;
dashboardParams = [dashboardId, companyCode, userId];
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND (d.created_by = $2 OR d.is_public = true)
`;
dashboardParams = [dashboardId, userId];
}
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.is_public = true
`;
dashboardParams = [dashboardId];
if (companyCode) {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.company_code = $2
AND d.is_public = true
`;
dashboardParams = [dashboardId, companyCode];
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.is_public = true
`;
dashboardParams = [dashboardId];
}
}
const dashboardResult = await PostgreSQLService.query(

View File

@ -1,4 +1,4 @@
import { query, queryOne } from "../database/db";
import { query, queryOne, transaction } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
@ -203,7 +203,8 @@ export class DynamicFormService {
async saveFormData(
screenId: number,
tableName: string,
data: Record<string, any>
data: Record<string, any>,
ipAddress?: string
): Promise<FormDataResult> {
try {
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
@ -432,7 +433,19 @@ export class DynamicFormService {
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
console.log("📊 SQL 파라미터:", values);
const result = await query<any>(upsertQuery, values);
// 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서)
const userId = data.updated_by || data.created_by || "system";
const clientIp = ipAddress || "unknown";
const result = await transaction(async (client) => {
// 세션 변수 설정
await client.query(`SET LOCAL app.user_id = '${userId}'`);
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
// UPSERT 실행
const res = await client.query(upsertQuery, values);
return res.rows;
});
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);

View File

@ -3118,4 +3118,410 @@ export class TableManagementService {
// 기본값
return "text";
}
// ========================================
// 🎯 테이블 로그 시스템
// ========================================
/**
*
*/
async createLogTable(
tableName: string,
pkColumn: { columnName: string; dataType: string },
userId?: string
): Promise<void> {
try {
const logTableName = `${tableName}_log`;
const triggerFuncName = `${tableName}_log_trigger_func`;
const triggerName = `${tableName}_audit_trigger`;
logger.info(`로그 테이블 생성 시작: ${logTableName}`);
// 로그 테이블 DDL 생성
const logTableDDL = this.generateLogTableDDL(
logTableName,
tableName,
pkColumn.columnName,
pkColumn.dataType
);
// 트리거 함수 DDL 생성
const triggerFuncDDL = this.generateTriggerFunctionDDL(
triggerFuncName,
logTableName,
tableName,
pkColumn.columnName
);
// 트리거 DDL 생성
const triggerDDL = this.generateTriggerDDL(
triggerName,
tableName,
triggerFuncName
);
// 트랜잭션으로 실행
await transaction(async (client) => {
// 1. 로그 테이블 생성
await client.query(logTableDDL);
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
// 2. 트리거 함수 생성
await client.query(triggerFuncDDL);
logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`);
// 3. 트리거 생성
await client.query(triggerDDL);
logger.info(`트리거 생성 완료: ${triggerName}`);
// 4. 로그 설정 저장
await client.query(
`INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, created_by
) VALUES ($1, $2, $3, $4, $5)`,
[tableName, logTableName, triggerName, triggerFuncName, userId]
);
logger.info(`로그 설정 저장 완료: ${tableName}`);
});
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
} catch (error) {
logger.error(`로그 테이블 생성 실패: ${tableName}`, error);
throw new Error(
`로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* DDL
*/
private generateLogTableDDL(
logTableName: string,
originalTableName: string,
pkColumnName: string,
pkDataType: string
): string {
return `
CREATE TABLE ${logTableName} (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력';
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID';
COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각';
COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP';
COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)';
COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)';
`;
}
/**
* DDL
*/
private generateTriggerFunctionDDL(
funcName: string,
logTableName: string,
originalTableName: string,
pkColumnName: string
): string {
return `
CREATE OR REPLACE FUNCTION ${funcName}()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ($1, ($2).%I, $3, $4, $5)',
'${pkColumnName}'
)
USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb;
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '${originalTableName}'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)',
'${pkColumnName}'
)
USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb;
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ($1, ($2).%I, $3, $4, $5)',
'${pkColumnName}'
)
USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
`;
}
/**
* DDL
*/
private generateTriggerDDL(
triggerName: string,
tableName: string,
funcName: string
): string {
return `
CREATE TRIGGER ${triggerName}
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
`;
}
/**
*
*/
async getLogConfig(tableName: string): Promise<{
originalTableName: string;
logTableName: string;
triggerName: string;
triggerFunctionName: string;
isActive: string;
createdAt: Date;
createdBy: string;
} | null> {
try {
logger.info(`로그 설정 조회: ${tableName}`);
const result = await queryOne<{
original_table_name: string;
log_table_name: string;
trigger_name: string;
trigger_function_name: string;
is_active: string;
created_at: Date;
created_by: string;
}>(
`SELECT
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_at, created_by
FROM table_log_config
WHERE original_table_name = $1`,
[tableName]
);
if (!result) {
return null;
}
return {
originalTableName: result.original_table_name,
logTableName: result.log_table_name,
triggerName: result.trigger_name,
triggerFunctionName: result.trigger_function_name,
isActive: result.is_active,
createdAt: result.created_at,
createdBy: result.created_by,
};
} catch (error) {
logger.error(`로그 설정 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
*
*/
async getLogData(
tableName: string,
options: {
page: number;
size: number;
operationType?: string;
startDate?: string;
endDate?: string;
changedBy?: string;
originalId?: string;
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const logTableName = `${tableName}_log`;
const offset = (options.page - 1) * options.size;
logger.info(`로그 데이터 조회: ${logTableName}`, options);
// WHERE 조건 구성
const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (options.operationType) {
whereConditions.push(`operation_type = $${paramIndex}`);
values.push(options.operationType);
paramIndex++;
}
if (options.startDate) {
whereConditions.push(`changed_at >= $${paramIndex}::timestamp`);
values.push(options.startDate);
paramIndex++;
}
if (options.endDate) {
whereConditions.push(`changed_at <= $${paramIndex}::timestamp`);
values.push(options.endDate);
paramIndex++;
}
if (options.changedBy) {
whereConditions.push(`changed_by = $${paramIndex}`);
values.push(options.changedBy);
paramIndex++;
}
if (options.originalId) {
whereConditions.push(`original_id::text = $${paramIndex}`);
values.push(options.originalId);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`;
const countResult = await query<any>(countQuery, values);
const total = parseInt(countResult[0].count);
// 데이터 조회
const dataQuery = `
SELECT * FROM ${logTableName}
${whereClause}
ORDER BY changed_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await query<any>(dataQuery, [
...values,
options.size,
offset,
]);
const totalPages = Math.ceil(total / options.size);
logger.info(
`로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환`
);
return {
data,
total,
page: options.page,
size: options.size,
totalPages,
};
} catch (error) {
logger.error(`로그 데이터 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* /
*/
async toggleLogTable(tableName: string, isActive: boolean): Promise<void> {
try {
const logConfig = await this.getLogConfig(tableName);
if (!logConfig) {
throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`);
}
logger.info(
`로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}`
);
await transaction(async (client) => {
// 트리거 활성화/비활성화
if (isActive) {
await client.query(
`ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}`
);
} else {
await client.query(
`ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}`
);
}
// 설정 업데이트
await client.query(
`UPDATE table_log_config
SET is_active = $1, updated_at = NOW()
WHERE original_table_name = $2`,
[isActive ? "Y" : "N", tableName]
);
});
logger.info(
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}`
);
} catch (error) {
logger.error(
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`,
error
);
throw error;
}
}
}

View File

@ -1,20 +1,14 @@
"use client";
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
AlertDialog,
AlertDialogAction,
@ -25,8 +19,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreHorizontal } from "lucide-react";
/**
*
@ -35,27 +30,38 @@ import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lu
*/
export default function DashboardListPage() {
const router = useRouter();
const { toast } = useToast();
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [error, setError] = useState<string | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalCount, setTotalCount] = useState(0);
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
setLoading(true);
setError(null);
const result = await dashboardApi.getMyDashboards({ search: searchTerm });
const result = await dashboardApi.getMyDashboards({
search: searchTerm,
page: currentPage,
limit: pageSize,
});
setDashboards(result.dashboards);
setTotalCount(result.pagination.total);
} catch (err) {
console.error("Failed to load dashboards:", err);
setError("대시보드 목록을 불러오는데 실패했습니다.");
toast({
title: "오류",
description: "대시보드 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setLoading(false);
}
@ -63,7 +69,29 @@ export default function DashboardListPage() {
useEffect(() => {
loadDashboards();
}, [searchTerm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, currentPage, pageSize]);
// 페이지네이션 정보 계산
const paginationInfo: PaginationInfo = {
currentPage,
totalPages: Math.ceil(totalCount / pageSize),
totalItems: totalCount,
itemsPerPage: pageSize,
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
endItem: Math.min(currentPage * pageSize, totalCount),
};
// 페이지 변경 핸들러
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
};
// 대시보드 삭제 확인 모달 열기
const handleDeleteClick = (id: string, title: string) => {
@ -79,37 +107,48 @@ export default function DashboardListPage() {
await dashboardApi.deleteDashboard(deleteTarget.id);
setDeleteDialogOpen(false);
setDeleteTarget(null);
setSuccessMessage("대시보드가 삭제되었습니다.");
setSuccessDialogOpen(true);
toast({
title: "성공",
description: "대시보드가 삭제되었습니다.",
});
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
setDeleteDialogOpen(false);
setError("대시보드 삭제에 실패했습니다.");
toast({
title: "오류",
description: "대시보드 삭제에 실패했습니다.",
variant: "destructive",
});
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
// 전체 대시보드 정보(요소 포함)를 가져오기
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
const newDashboard = await dashboardApi.createDashboard({
await dashboardApi.createDashboard({
title: `${fullDashboard.title} (복사본)`,
description: fullDashboard.description,
elements: fullDashboard.elements || [],
isPublic: false,
tags: fullDashboard.tags,
category: fullDashboard.category,
settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
});
toast({
title: "성공",
description: "대시보드가 복사되었습니다.",
});
setSuccessMessage("대시보드가 복사되었습니다.");
setSuccessDialogOpen(true);
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
setError("대시보드 복사에 실패했습니다.");
toast({
title: "오류",
description: "대시보드 복사에 실패했습니다.",
variant: "destructive",
});
}
};
@ -119,121 +158,137 @@ export default function DashboardListPage() {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium text-gray-900"> ...</div>
<div className="mt-2 text-sm text-gray-500"> </div>
</div>
</div>
);
}
return (
<div className="h-full overflow-auto bg-gray-50 p-6">
<div className="mx-auto max-w-7xl">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-sm text-gray-600"> </p>
</div>
{/* 액션 바 */}
<div className="mb-6 flex items-center justify-between">
<div className="relative w-64">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
<div className="min-h-[calc(100vh-4rem)] bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 에러 메시지 */}
{error && (
<Card className="mb-6 border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
{/* 검색 및 필터 */}
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 pl-10"
/>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 대시보드 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-gray-500"> ...</div>
</div>
) : dashboards.length === 0 ? (
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="py-8 text-center text-gray-500">
<LayoutDashboard className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-sm text-gray-400"> .</p>
<Button onClick={() => router.push("/admin/dashboard/new")}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<CardContent className="p-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-gray-50">
<TableCell>
<div className="font-medium">{dashboard.title}</div>
</TableCell>
<TableCell className="max-w-md truncate text-sm text-gray-500">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-sm">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="text-right">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="h-8 w-full justify-start gap-2 px-2 text-xs"
>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(dashboard)}
className="h-8 w-full justify-start gap-2 px-2 text-xs"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="h-8 w-full justify-start gap-2 px-2 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</PopoverContent>
</Popover>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* 대시보드 목록 */}
{dashboards.length === 0 ? (
<Card className="p-12 text-center">
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
<Plus className="h-12 w-12 text-gray-400" />
</div>
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="mb-6 text-sm text-gray-500"> </p>
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
<TableCell className="font-medium">{dashboard.title}</TableCell>
<TableCell className="max-w-md truncate text-sm text-gray-500">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="gap-2"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="gap-2 text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* 페이지네이션 */}
{!loading && dashboards.length > 0 && (
<Pagination
paginationInfo={paginationInfo}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showPageSizeSelector={true}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</div>
@ -241,36 +296,24 @@ export default function DashboardListPage() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.title}&quot; ?
<br /> .
<br />
<span className="font-medium text-red-600"> .</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-red-600 hover:bg-red-700">
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 성공 모달 */}
<Dialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<DialogTitle className="text-center"></DialogTitle>
<DialogDescription className="text-center">{successMessage}</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4">
<Button onClick={() => setSuccessDialogOpen(false)}></Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -20,6 +20,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
import { TableLogViewer } from "@/components/admin/TableLogViewer";
// 가상화 스크롤링을 위한 간단한 구현
interface TableInfo {
@ -76,6 +77,10 @@ export default function TableManagementPage() {
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
const isSuperAdmin = user?.companyCode === "*";
@ -645,15 +650,30 @@ export default function TableManagementPage() {
onClick={() => handleTableSelect(table.tableName)}
>
<div className="flex items-center justify-between">
<div>
<div className="flex-1">
<h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
<p className="text-sm text-gray-500">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
</div>
<Badge variant="secondary">
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
</Badge>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
</Badge>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setLogViewerTableName(table.tableName);
setLogViewerOpen(true);
}}
title="변경 이력 조회"
>
<Activity className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))
@ -972,6 +992,9 @@ export default function TableManagementPage() {
/>
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
{/* 테이블 로그 뷰어 */}
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
</>
)}
</div>

View File

@ -19,10 +19,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react";
import { toast } from "sonner";
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
import { ddlApi } from "../../lib/api/ddl";
import { tableManagementApi } from "../../lib/api/tableManagement";
import {
CreateTableModalProps,
CreateColumnDefinition,
@ -47,6 +49,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
const [validating, setValidating] = useState(false);
const [tableNameError, setTableNameError] = useState("");
const [validationResult, setValidationResult] = useState<any>(null);
const [useLogTable, setUseLogTable] = useState(false);
/**
*
@ -65,6 +68,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
]);
setTableNameError("");
setValidationResult(null);
setUseLogTable(false);
};
/**
@ -204,6 +208,23 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
if (result.success) {
toast.success(result.message);
// 로그 테이블 생성 옵션이 선택되었다면 로그 테이블 생성
if (useLogTable) {
try {
const pkColumn = { columnName: "id", dataType: "integer" };
const logResult = await tableManagementApi.createLogTable(tableName, pkColumn);
if (logResult.success) {
toast.success(`${tableName}_log 테이블이 생성되었습니다.`);
} else {
toast.warning(`테이블은 생성되었으나 로그 테이블 생성 실패: ${logResult.message}`);
}
} catch (logError) {
toast.warning("테이블은 생성되었으나 로그 테이블 생성 중 오류가 발생했습니다.");
}
}
onSuccess(result);
onClose();
} else {
@ -248,7 +269,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
placeholder="예: customer_info"
className={tableNameError ? "border-red-300" : ""}
/>
{tableNameError && <p className="text-sm text-destructive">{tableNameError}</p>}
{tableNameError && <p className="text-destructive text-sm">{tableNameError}</p>}
<p className="text-muted-foreground text-xs"> , // </p>
</div>
@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
</div>
{/* 로그 테이블 생성 옵션 */}
<div className="flex items-start space-x-3 rounded-lg border p-4">
<Checkbox
id="useLogTable"
checked={useLogTable}
onCheckedChange={(checked) => setUseLogTable(checked as boolean)}
disabled={loading}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="useLogTable"
className="flex cursor-pointer items-center gap-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Activity className="h-4 w-4" />
</label>
<p className="text-muted-foreground text-xs">
<code className="bg-muted rounded px-1 py-0.5">{tableName || "table"}_log</code>
INSERT/UPDATE/DELETE .
</p>
</div>
</div>
{/* 자동 추가 컬럼 안내 */}
<Alert>
<Info className="h-4 w-4" />

View File

@ -0,0 +1,261 @@
"use client";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { History, RefreshCw, Filter, X } from "lucide-react";
interface TableLogViewerProps {
tableName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface LogData {
log_id: number;
operation_type: string;
original_id: string;
changed_column?: string;
old_value?: string;
new_value?: string;
changed_by?: string;
changed_at: string;
ip_address?: string;
}
export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewerProps) {
const [logs, setLogs] = useState<LogData[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [totalPages, setTotalPages] = useState(0);
// 필터 상태
const [operationType, setOperationType] = useState<string>("");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [changedBy, setChangedBy] = useState<string>("");
const [originalId, setOriginalId] = useState<string>("");
// 로그 데이터 로드
const loadLogs = async () => {
if (!tableName) return;
setLoading(true);
try {
const response = await tableManagementApi.getLogData(tableName, {
page,
size: pageSize,
operationType: operationType || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
changedBy: changedBy || undefined,
originalId: originalId || undefined,
});
if (response.success && response.data) {
setLogs(response.data.data);
setTotal(response.data.total);
setTotalPages(response.data.totalPages);
} else {
toast.error(response.message || "로그 데이터를 불러올 수 없습니다.");
}
} catch (error) {
toast.error("로그 데이터 조회 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
// 다이얼로그가 열릴 때 로그 로드
useEffect(() => {
if (open && tableName) {
loadLogs();
}
}, [open, tableName, page]);
// 필터 초기화
const resetFilters = () => {
setOperationType("");
setStartDate("");
setEndDate("");
setChangedBy("");
setOriginalId("");
setPage(1);
};
// 작업 타입에 따른 뱃지 색상
const getOperationBadge = (type: string) => {
switch (type) {
case "INSERT":
return <Badge className="bg-green-500"></Badge>;
case "UPDATE":
return <Badge className="bg-blue-500"></Badge>;
case "DELETE":
return <Badge className="bg-red-500"></Badge>;
default:
return <Badge>{type}</Badge>;
}
};
// 날짜 포맷팅
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
{tableName} -
</DialogTitle>
</DialogHeader>
{/* 필터 영역 */}
<div className="space-y-3 rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="flex items-center gap-2 text-sm font-semibold">
<Filter className="h-4 w-4" />
</h4>
<Button variant="ghost" size="sm" onClick={resetFilters}>
<X className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm text-gray-600"> </label>
<Select value={operationType} onValueChange={setOperationType}>
<SelectTrigger>
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="INSERT"></SelectItem>
<SelectItem value="UPDATE"></SelectItem>
<SelectItem value="DELETE"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-sm text-gray-600"> </label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-sm text-gray-600"> </label>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-sm text-gray-600"></label>
<Input placeholder="사용자 ID" value={changedBy} onChange={(e) => setChangedBy(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-sm text-gray-600"> ID</label>
<Input placeholder="레코드 ID" value={originalId} onChange={(e) => setOriginalId(e.target.value)} />
</div>
<div className="flex items-end">
<Button onClick={loadLogs} className="w-full">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 로그 테이블 */}
<div className="flex-1 overflow-auto rounded-lg border">
{loading ? (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
) : logs.length === 0 ? (
<div className="flex h-64 items-center justify-center text-gray-500"> .</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead> ID</TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead>IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.log_id}>
<TableCell>{getOperationBadge(log.operation_type)}</TableCell>
<TableCell className="font-mono text-sm">{log.original_id}</TableCell>
<TableCell className="text-sm">{log.changed_column || "-"}</TableCell>
<TableCell className="max-w-[200px] truncate text-sm" title={log.old_value}>
{log.old_value || "-"}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm" title={log.new_value}>
{log.new_value || "-"}
</TableCell>
<TableCell className="text-sm">{log.changed_by || "system"}</TableCell>
<TableCell className="text-sm">{formatDate(log.changed_at)}</TableCell>
<TableCell className="font-mono text-xs">{log.ip_address || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-between border-t pt-4">
<div className="text-sm text-gray-600">
{total} ( {page} / {totalPages})
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || loading}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -105,6 +105,8 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
// 기사 관리 위젯 임포트
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
import { ListWidget } from "./widgets/ListWidget";
import { MoreHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
// 야드 관리 3D 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
@ -541,27 +543,31 @@ export function CanvasElement({
onMouseDown={handleMouseDown}
>
{/* 헤더 */}
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<div className="flex cursor-move items-center justify-between p-3">
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
<MoreHorizontal className="h-4 w-4" />
</Button>
)}
{/* 삭제 버튼 */}
<button
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
<Button
variant="ghost"
size="icon"
className="element-close hover:bg-destructive h-6 w-6 text-gray-400 hover:text-white"
onClick={handleRemove}
title="삭제"
>
×
</button>
<X className="h-4 w-4" />
</Button>
</div>
</div>

View File

@ -53,9 +53,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
element.subtype === "driver-management" ||
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요)
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
const isSelfContainedWidget =
const isSelfContainedWidget =
element.subtype === "weather" || // 날씨 위젯 (외부 API)
element.subtype === "exchange" || // 환율 위젯 (외부 API)
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
@ -150,11 +150,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
if (!isOpen) return null;
// 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능
const isHeaderOnlyWidget =
element.type === "widget" &&
(element.subtype === "clock" ||
element.subtype === "calendar" ||
isSelfContainedWidget);
const isHeaderOnlyWidget =
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (element.type === "widget" && element.subtype === "driver-management") {
@ -172,7 +170,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// customTitle이 변경되었는지 확인
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
// showHeader가 변경되었는지 확인
const isHeaderChanged = showHeader !== (element.showHeader !== false);
@ -214,13 +212,6 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<div className="flex items-center justify-between">
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">{element.title} </h2>
<p className="mt-1 text-sm text-gray-500">
{isSimpleWidget
? "데이터 소스를 설정하세요"
: currentStep === 1
? "데이터 소스를 선택하세요"
: "쿼리를 실행하고 차트를 설정하세요"}
</p>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<X className="h-5 w-5" />
@ -241,7 +232,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500"> (: "maintenance_schedules 목록")</p>
<p className="mt-1 text-xs text-gray-500">
(: "maintenance_schedules 목록")
</p>
</div>
{/* 헤더 표시 옵션 */}
@ -251,7 +244,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
id="showHeader"
checked={showHeader}
onChange={(e) => setShowHeader(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
( + )
@ -278,61 +271,65 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{currentStep === 2 && (
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
{/* 왼쪽: 데이터 설정 */}
<div className="space-y-6">
{dataSource.type === "database" ? (
<>
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
{/* 왼쪽: 데이터 설정 */}
<div className="space-y-6">
{dataSource.type === "database" ? (
<>
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</>
) : (
<ApiConfig
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
onChange={handleDataSourceUpdate}
onTestResult={handleQueryTest}
/>
</>
) : (
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
)}
</div>
)}
</div>
{/* 오른쪽: 설정 패널 */}
{!isSimpleWidget && (
<div>
{isMapWidget ? (
// 지도 위젯: 위도/경도 매핑 패널
{/* 오른쪽: 설정 패널 */}
{!isSimpleWidget && (
<div>
{isMapWidget ? (
// 지도 위젯: 위도/경도 매핑 패널
queryResult && queryResult.rows.length > 0 ? (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
<div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
</div>
)
) : // 차트: 차트 설정 패널
queryResult && queryResult.rows.length > 0 ? (
<VehicleMapConfigPanel
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
<div>
<div className="mt-1 text-xs text-gray-500"> </div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
</div>
)
) : // 차트: 차트 설정 패널
queryResult && queryResult.rows.length > 0 ? (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
<div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
@ -376,4 +373,3 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
</div>
);
}

View File

@ -51,7 +51,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
if (element.dataSource.queryParams) {
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
if (key && value) {
params.append(key, value);
params.append(key, String(value));
}
});
}
@ -158,11 +158,15 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
element.dataSource?.query,
element.dataSource?.connectionType,
element.dataSource?.externalConnectionId,
element.dataSource?.refreshInterval,
element.dataSource?.type,
element.dataSource?.endpoint,
element.dataSource?.jsonPath,
element.chartConfig,
data,
]);
@ -201,9 +205,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
return (
<div className="flex h-full w-full items-center justify-center text-gray-500">
<div className="text-center">
<div className="mb-2 text-2xl">📊</div>
<div className="text-sm"> </div>
<div className="mt-1 text-xs"> </div>
</div>
</div>
);

View File

@ -44,9 +44,9 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: "digital", label: "디지털", icon: "🔢" },
{ value: "analog", label: "아날로그", icon: "🕐" },
{ value: "both", label: "둘 다", icon: "⏰" },
{ value: "digital", label: "디지털" },
{ value: "analog", label: "아날로그" },
{ value: "both", label: "둘 다" },
].map((style) => (
<Button
key={style.value}
@ -56,7 +56,6 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
className="flex h-auto flex-col items-center gap-1 py-3"
size="sm"
>
<span className="text-2xl">{style.icon}</span>
<span className="text-xs">{style.label}</span>
</Button>
))}

View File

@ -7,6 +7,7 @@ import * as THREE from "three";
interface YardPlacement {
id: number;
yard_layout_id?: number;
material_code?: string | null;
material_name?: string | null;
quantity?: number | null;
@ -26,7 +27,7 @@ interface YardPlacement {
interface Yard3DCanvasProps {
placements: YardPlacement[];
selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement) => void;
onPlacementClick: (placement: YardPlacement | null) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
}

View File

@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react";
interface YardPlacement {
id: number;
yard_layout_id: number;
yard_layout_id?: number;
material_code?: string | null;
material_name?: string | null;
quantity?: number | null;
@ -20,12 +20,20 @@ interface YardPlacement {
size_z: number;
color: string;
data_source_type?: string | null;
data_source_config?: any;
data_binding?: any;
data_source_config?: Record<string, unknown> | null;
data_binding?: Record<string, unknown> | null;
status?: string;
memo?: string;
}
interface YardLayout {
id: number;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
}
interface Yard3DViewerProps {
layoutId: number;
}
@ -58,13 +66,14 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
// 야드 레이아웃 정보 조회
const layoutResponse = await yardLayoutApi.getLayoutById(layoutId);
if (layoutResponse.success) {
setLayoutName(layoutResponse.data.name);
const layout = layoutResponse.data as YardLayout;
setLayoutName(layout.name);
}
// 배치 데이터 조회
const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId);
if (placementsResponse.success) {
setPlacements(placementsResponse.data);
setPlacements(placementsResponse.data as YardPlacement[]);
} else {
setError("배치 데이터를 불러올 수 없습니다.");
}
@ -123,7 +132,7 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
{/* 야드 이름 (좌측 상단) */}
{layoutName && (
<div className="absolute top-4 left-4 z-50 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
<div className="absolute top-4 left-4 z-49 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
</div>
)}

View File

@ -401,7 +401,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
onLogout={handleLogout}
/>
<div className="flex flex-1">
<div className="flex flex-1 pt-14">
{/* 모바일 사이드바 오버레이 */}
{sidebarOpen && isMobile && (
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
: "relative top-0 z-auto translate-x-0"
} flex h-full w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
>
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{(user as ExtendedUserInfo)?.userType === "admin" && (

View File

@ -14,7 +14,7 @@ interface MainHeaderProps {
*/
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
return (
<header className="bg-background/95 sticky top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
<div className="flex h-full w-full items-center justify-between px-6">
{/* Left side - Side Menu + Logo */}
<div className="flex h-8 items-center gap-2">

View File

@ -211,6 +211,114 @@ class TableManagementApi {
};
}
}
// ========================================
// 테이블 로그 시스템 API
// ========================================
/**
*
*/
async createLogTable(
tableName: string,
pkColumn: { columnName: string; dataType: string },
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log`, { pkColumn });
return response.data;
} catch (error: any) {
console.error(`❌ 로그 테이블 생성 실패: ${tableName}`, error);
return {
success: false,
message: error.response?.data?.message || error.message || "로그 테이블을 생성할 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
}
/**
*
*/
async getLogConfig(tableName: string): Promise<
ApiResponse<{
originalTableName: string;
logTableName: string;
triggerName: string;
triggerFunctionName: string;
isActive: string;
createdAt: Date;
createdBy: string;
} | null>
> {
try {
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log/config`);
return response.data;
} catch (error: any) {
console.error(`❌ 로그 설정 조회 실패: ${tableName}`, error);
return {
success: false,
message: error.response?.data?.message || error.message || "로그 설정을 조회할 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
}
/**
*
*/
async getLogData(
tableName: string,
options: {
page?: number;
size?: number;
operationType?: string;
startDate?: string;
endDate?: string;
changedBy?: string;
originalId?: string;
} = {},
): Promise<
ApiResponse<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}>
> {
try {
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log`, {
params: options,
});
return response.data;
} catch (error: any) {
console.error(`❌ 로그 데이터 조회 실패: ${tableName}`, error);
return {
success: false,
message: error.response?.data?.message || error.message || "로그 데이터를 조회할 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
}
/**
* /
*/
async toggleLogTable(tableName: string, isActive: boolean): Promise<ApiResponse<void>> {
try {
const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log/toggle`, {
isActive,
});
return response.data;
} catch (error: any) {
console.error(`❌ 로그 테이블 토글 실패: ${tableName}`, error);
return {
success: false,
message: error.response?.data?.message || error.message || "로그 테이블 설정을 변경할 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
}
}
// 싱글톤 인스턴스 생성

View File

@ -0,0 +1,773 @@
# 테이블 변경 이력 로그 시스템 구현 계획서
## 1. 개요
테이블 생성 시 해당 테이블의 변경 이력을 자동으로 기록하는 로그 테이블 생성 기능을 추가합니다.
사용자가 테이블을 생성할 때 로그 테이블 생성 여부를 선택할 수 있으며, 선택 시 자동으로 로그 테이블과 트리거가 생성됩니다.
## 2. 핵심 기능
### 2.1 로그 테이블 생성 옵션
- 테이블 생성 폼에 "변경 이력 로그 테이블 생성" 체크박스 추가
- 체크 시 `{원본테이블명}_log` 형식의 로그 테이블 자동 생성
### 2.2 로그 테이블 스키마 구조
```sql
CREATE TABLE {table_name}_log (
log_id SERIAL PRIMARY KEY, -- 로그 고유 ID
operation_type VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
original_id {원본PK타입}, -- 원본 테이블의 PK 값
changed_column VARCHAR(100), -- 변경된 컬럼명 (UPDATE 시)
old_value TEXT, -- 변경 전 값
new_value TEXT, -- 변경 후 값
changed_by VARCHAR(50), -- 변경한 사용자 ID
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
ip_address VARCHAR(50), -- 변경 요청 IP
user_agent TEXT, -- 변경 요청 User-Agent
full_row_before JSONB, -- 변경 전 전체 행 (JSON)
full_row_after JSONB -- 변경 후 전체 행 (JSON)
);
CREATE INDEX idx_{table_name}_log_original_id ON {table_name}_log(original_id);
CREATE INDEX idx_{table_name}_log_changed_at ON {table_name}_log(changed_at);
CREATE INDEX idx_{table_name}_log_operation ON {table_name}_log(operation_type);
```
### 2.3 트리거 함수 생성
```sql
CREATE OR REPLACE FUNCTION {table_name}_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
-- 세션 변수에서 사용자 정보 가져오기
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO {table_name}_log (
operation_type, original_id, changed_by, ip_address,
full_row_after
) VALUES (
'INSERT', NEW.{pk_column}, v_user_id, v_ip_address,
row_to_json(NEW)::jsonb
);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
-- 각 컬럼별로 변경사항 기록
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = TG_TABLE_NAME
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT',
v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO {table_name}_log (
operation_type, original_id, changed_column,
old_value, new_value, changed_by, ip_address,
full_row_before, full_row_after
) VALUES (
'UPDATE', NEW.{pk_column}, v_column_name,
v_old_value, v_new_value, v_user_id, v_ip_address,
row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO {table_name}_log (
operation_type, original_id, changed_by, ip_address,
full_row_before
) VALUES (
'DELETE', OLD.{pk_column}, v_user_id, v_ip_address,
row_to_json(OLD)::jsonb
);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
```
### 2.4 트리거 생성
```sql
CREATE TRIGGER {table_name}_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON {table_name}
FOR EACH ROW EXECUTE FUNCTION {table_name}_log_trigger_func();
```
## 3. 데이터베이스 스키마 변경
### 3.1 table_type_mng 테이블 수정
```sql
ALTER TABLE table_type_mng
ADD COLUMN use_log_table VARCHAR(1) DEFAULT 'N';
COMMENT ON COLUMN table_type_mng.use_log_table IS '변경 이력 로그 테이블 사용 여부 (Y/N)';
```
### 3.2 새로운 관리 테이블 추가
```sql
CREATE TABLE table_log_config (
config_id SERIAL PRIMARY KEY,
original_table_name VARCHAR(100) NOT NULL,
log_table_name VARCHAR(100) NOT NULL,
trigger_name VARCHAR(100) NOT NULL,
trigger_function_name VARCHAR(100) NOT NULL,
is_active VARCHAR(1) DEFAULT 'Y',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
UNIQUE(original_table_name)
);
COMMENT ON TABLE table_log_config IS '테이블 로그 설정 관리';
COMMENT ON COLUMN table_log_config.original_table_name IS '원본 테이블명';
COMMENT ON COLUMN table_log_config.log_table_name IS '로그 테이블명';
COMMENT ON COLUMN table_log_config.trigger_name IS '트리거명';
COMMENT ON COLUMN table_log_config.trigger_function_name IS '트리거 함수명';
COMMENT ON COLUMN table_log_config.is_active IS '활성 상태 (Y/N)';
```
## 4. 백엔드 구현
### 4.1 Service Layer 수정
**파일**: `backend-node/src/services/admin/table-type-mng.service.ts`
#### 4.1.1 로그 테이블 생성 로직
```typescript
/**
* 로그 테이블 생성
*/
private async createLogTable(
tableName: string,
columns: any[],
connectionId?: number,
userId?: string
): Promise<void> {
const logTableName = `${tableName}_log`;
const triggerFuncName = `${tableName}_log_trigger_func`;
const triggerName = `${tableName}_audit_trigger`;
// PK 컬럼 찾기
const pkColumn = columns.find(col => col.isPrimaryKey);
if (!pkColumn) {
throw new Error('PK 컬럼이 없으면 로그 테이블을 생성할 수 없습니다.');
}
// 로그 테이블 DDL 생성
const logTableDDL = this.generateLogTableDDL(
logTableName,
pkColumn.COLUMN_NAME,
pkColumn.DATA_TYPE
);
// 트리거 함수 DDL 생성
const triggerFuncDDL = this.generateTriggerFunctionDDL(
triggerFuncName,
logTableName,
tableName,
pkColumn.COLUMN_NAME
);
// 트리거 DDL 생성
const triggerDDL = this.generateTriggerDDL(
triggerName,
tableName,
triggerFuncName
);
try {
// 1. 로그 테이블 생성
await this.executeDDL(logTableDDL, connectionId);
// 2. 트리거 함수 생성
await this.executeDDL(triggerFuncDDL, connectionId);
// 3. 트리거 생성
await this.executeDDL(triggerDDL, connectionId);
// 4. 로그 설정 저장
await this.saveLogConfig({
originalTableName: tableName,
logTableName,
triggerName,
triggerFunctionName: triggerFuncName,
createdBy: userId
});
console.log(`로그 테이블 생성 완료: ${logTableName}`);
} catch (error) {
console.error('로그 테이블 생성 실패:', error);
throw error;
}
}
/**
* 로그 테이블 DDL 생성
*/
private generateLogTableDDL(
logTableName: string,
pkColumnName: string,
pkDataType: string
): string {
return `
CREATE TABLE ${logTableName} (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id ${pkDataType},
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
COMMENT ON TABLE ${logTableName} IS '${logTableName.replace('_log', '')} 테이블 변경 이력';
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
`;
}
/**
* 트리거 함수 DDL 생성
*/
private generateTriggerFunctionDDL(
funcName: string,
logTableName: string,
originalTableName: string,
pkColumnName: string
): string {
return `
CREATE OR REPLACE FUNCTION ${funcName}()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO ${logTableName} (
operation_type, original_id, changed_by, ip_address, full_row_after
) VALUES (
'INSERT', NEW.${pkColumnName}, v_user_id, v_ip_address, row_to_json(NEW)::jsonb
);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '${originalTableName}'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO ${logTableName} (
operation_type, original_id, changed_column, old_value, new_value,
changed_by, ip_address, full_row_before, full_row_after
) VALUES (
'UPDATE', NEW.${pkColumnName}, v_column_name, v_old_value, v_new_value,
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO ${logTableName} (
operation_type, original_id, changed_by, ip_address, full_row_before
) VALUES (
'DELETE', OLD.${pkColumnName}, v_user_id, v_ip_address, row_to_json(OLD)::jsonb
);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
`;
}
/**
* 트리거 DDL 생성
*/
private generateTriggerDDL(
triggerName: string,
tableName: string,
funcName: string
): string {
return `
CREATE TRIGGER ${triggerName}
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
`;
}
/**
* 로그 설정 저장
*/
private async saveLogConfig(config: {
originalTableName: string;
logTableName: string;
triggerName: string;
triggerFunctionName: string;
createdBy?: string;
}): Promise<void> {
const query = `
INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, created_by
) VALUES ($1, $2, $3, $4, $5)
`;
await this.executeQuery(query, [
config.originalTableName,
config.logTableName,
config.triggerName,
config.triggerFunctionName,
config.createdBy
]);
}
```
#### 4.1.2 테이블 생성 메서드 수정
```typescript
async createTable(params: {
tableName: string;
columns: any[];
useLogTable?: boolean; // 추가
connectionId?: number;
userId?: string;
}): Promise<void> {
const { tableName, columns, useLogTable, connectionId, userId } = params;
// 1. 원본 테이블 생성
const ddl = this.generateCreateTableDDL(tableName, columns);
await this.executeDDL(ddl, connectionId);
// 2. 로그 테이블 생성 (옵션)
if (useLogTable === true) {
await this.createLogTable(tableName, columns, connectionId, userId);
}
// 3. 메타데이터 저장
await this.saveTableMetadata({
tableName,
columns,
useLogTable: useLogTable ? 'Y' : 'N',
connectionId,
userId
});
}
```
### 4.2 Controller Layer 수정
**파일**: `backend-node/src/controllers/admin/table-type-mng.controller.ts`
```typescript
/**
* 테이블 생성
*/
async createTable(req: Request, res: Response): Promise<void> {
try {
const { tableName, columns, useLogTable, connectionId } = req.body;
const userId = req.user?.userId;
await this.tableTypeMngService.createTable({
tableName,
columns,
useLogTable: useLogTable === 'Y' || useLogTable === true,
connectionId,
userId
});
res.json({
success: true,
message: useLogTable
? '테이블 및 로그 테이블이 생성되었습니다.'
: '테이블이 생성되었습니다.'
});
} catch (error) {
console.error('테이블 생성 오류:', error);
res.status(500).json({
success: false,
message: '테이블 생성 중 오류가 발생했습니다.'
});
}
}
```
### 4.3 세션 변수 설정 미들웨어
**파일**: `backend-node/src/middleware/db-session.middleware.ts`
```typescript
import { Request, Response, NextFunction } from "express";
/**
* DB 세션 변수 설정 미들웨어
* 트리거에서 사용할 사용자 정보를 세션 변수에 설정
*/
export const setDBSessionVariables = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const userId = req.user?.userId || "system";
const ipAddress = req.ip || req.socket.remoteAddress || "unknown";
// PostgreSQL 세션 변수 설정
const queries = [
`SET app.user_id = '${userId}'`,
`SET app.ip_address = '${ipAddress}'`,
];
// 각 DB 연결에 세션 변수 설정
// (실제 구현은 DB 연결 풀 관리 방식에 따라 다름)
next();
} catch (error) {
console.error("DB 세션 변수 설정 오류:", error);
next(error);
}
};
```
## 5. 프론트엔드 구현
### 5.1 테이블 생성 폼 수정
**파일**: `frontend/src/app/admin/tableMng/components/TableCreateForm.tsx`
```typescript
const TableCreateForm = () => {
const [useLogTable, setUseLogTable] = useState<boolean>(false);
return (
<div className="table-create-form">
{/* 기존 폼 필드들 */}
{/* 로그 테이블 옵션 추가 */}
<div className="form-group">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={useLogTable}
onChange={(e) => setUseLogTable(e.target.checked)}
className="w-4 h-4"
/>
<span>변경 이력 로그 테이블 생성</span>
</label>
<p className="text-sm text-gray-600 mt-1">
체크 시 데이터 변경 이력을 기록하는 로그 테이블이 자동으로 생성됩니다.
(테이블명: {tableName}_log)
</p>
</div>
{useLogTable && (
<div className="bg-blue-50 p-4 rounded border border-blue-200">
<h4 className="font-semibold mb-2">로그 테이블 정보</h4>
<ul className="text-sm space-y-1">
<li>• INSERT/UPDATE/DELETE 작업이 자동으로 기록됩니다</li>
<li>• 변경 전후 값과 변경자 정보가 저장됩니다</li>
<li>
• 로그는 별도 테이블에 저장되어 원본 테이블 성능에 영향을
최소화합니다
</li>
</ul>
</div>
)}
</div>
);
};
```
### 5.2 로그 조회 화면 추가
**파일**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx`
```typescript
interface TableLogViewerProps {
tableName: string;
}
const TableLogViewer: React.FC<TableLogViewerProps> = ({ tableName }) => {
const [logs, setLogs] = useState<any[]>([]);
const [filters, setFilters] = useState({
operationType: "",
startDate: "",
endDate: "",
changedBy: "",
});
const fetchLogs = async () => {
// 로그 데이터 조회
const response = await fetch(`/api/admin/table-log/${tableName}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(filters),
});
const data = await response.json();
setLogs(data.logs);
};
return (
<div className="table-log-viewer">
<h3>변경 이력 조회: {tableName}</h3>
{/* 필터 */}
<div className="filters">
<select
value={filters.operationType}
onChange={(e) =>
setFilters({ ...filters, operationType: e.target.value })
}
>
<option value="">전체</option>
<option value="INSERT">추가</option>
<option value="UPDATE">수정</option>
<option value="DELETE">삭제</option>
</select>
{/* 날짜 필터, 사용자 필터 등 */}
</div>
{/* 로그 테이블 */}
<table className="log-table">
<thead>
<tr>
<th>작업유형</th>
<th>원본ID</th>
<th>변경컬럼</th>
<th>변경전</th>
<th>변경후</th>
<th>변경자</th>
<th>변경시각</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.log_id}>
<td>{log.operation_type}</td>
<td>{log.original_id}</td>
<td>{log.changed_column}</td>
<td>{log.old_value}</td>
<td>{log.new_value}</td>
<td>{log.changed_by}</td>
<td>{log.changed_at}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
```
## 6. API 엔드포인트
### 6.1 로그 조회 API
```
POST /api/admin/table-log/:tableName
Request Body:
{
"operationType": "UPDATE", // 선택: INSERT, UPDATE, DELETE
"startDate": "2024-01-01", // 선택
"endDate": "2024-12-31", // 선택
"changedBy": "user123", // 선택
"originalId": 123 // 선택
}
Response:
{
"success": true,
"logs": [
{
"log_id": 1,
"operation_type": "UPDATE",
"original_id": "123",
"changed_column": "user_name",
"old_value": "홍길동",
"new_value": "김철수",
"changed_by": "admin",
"changed_at": "2024-10-21T10:30:00Z",
"ip_address": "192.168.1.100"
}
]
}
```
### 6.2 로그 테이블 활성화/비활성화 API
```
POST /api/admin/table-log/:tableName/toggle
Request Body:
{
"isActive": "Y" // Y 또는 N
}
Response:
{
"success": true,
"message": "로그 기능이 활성화되었습니다."
}
```
## 7. 테스트 계획
### 7.1 단위 테스트
- [ ] 로그 테이블 DDL 생성 함수 테스트
- [ ] 트리거 함수 DDL 생성 함수 테스트
- [ ] 트리거 DDL 생성 함수 테스트
- [ ] 로그 설정 저장 함수 테스트
### 7.2 통합 테스트
- [ ] 테이블 생성 시 로그 테이블 자동 생성 테스트
- [ ] INSERT 작업 시 로그 기록 테스트
- [ ] UPDATE 작업 시 로그 기록 테스트
- [ ] DELETE 작업 시 로그 기록 테스트
- [ ] 여러 컬럼 동시 변경 시 로그 기록 테스트
### 7.3 성능 테스트
- [ ] 대량 데이터 INSERT 시 성능 영향 측정
- [ ] 대량 데이터 UPDATE 시 성능 영향 측정
- [ ] 로그 테이블 크기 증가에 따른 성능 영향 측정
## 8. 주의사항 및 제약사항
### 8.1 성능 고려사항
- 트리거는 모든 변경 작업에 대해 실행되므로 성능 영향이 있을 수 있음
- 대량 데이터 처리 시 로그 테이블 크기가 급격히 증가할 수 있음
- 로그 테이블에 적절한 인덱스 설정 필요
### 8.2 운영 고려사항
- 로그 데이터의 보관 주기 정책 수립 필요
- 오래된 로그 데이터 아카이빙 전략 필요
- 로그 테이블의 정기적인 파티셔닝 고려
### 8.3 보안 고려사항
- 로그 데이터에는 민감한 정보가 포함될 수 있으므로 접근 권한 관리 필요
- 로그 데이터 자체의 무결성 보장 필요
- 로그 데이터의 암호화 저장 고려
## 9. 향후 확장 계획
### 9.1 로그 분석 기능
- 변경 패턴 분석
- 사용자별 변경 통계
- 시간대별 변경 추이
### 9.2 로그 알림 기능
- 특정 테이블/컬럼 변경 시 알림
- 비정상적인 대량 변경 감지
- 특정 사용자의 변경 작업 모니터링
### 9.3 로그 복원 기능
- 특정 시점으로 데이터 롤백
- 변경 이력 기반 데이터 복구
- 변경 이력 시각화
## 10. 마이그레이션 가이드
### 10.1 기존 테이블에 로그 기능 추가
```typescript
// 기존 테이블에 로그 테이블 추가하는 API
POST /api/admin/table-log/:tableName/enable
// 실행 순서:
// 1. 로그 테이블 생성
// 2. 트리거 함수 생성
// 3. 트리거 생성
// 4. 로그 설정 저장
```
### 10.2 로그 기능 제거
```typescript
// 로그 기능 제거 API
POST /api/admin/table-log/:tableName/disable
// 실행 순서:
// 1. 트리거 삭제
// 2. 트리거 함수 삭제
// 3. 로그 테이블 삭제 (선택)
// 4. 로그 설정 비활성화
```
## 11. 개발 우선순위
### Phase 1: 기본 기능 (필수)
1. DB 스키마 변경 (table_type_mng, table_log_config)
2. 로그 테이블 DDL 생성 로직
3. 트리거 함수/트리거 DDL 생성 로직
4. 테이블 생성 시 로그 테이블 자동 생성
### Phase 2: UI 개발
1. 테이블 생성 폼에 로그 옵션 추가
2. 로그 조회 화면 개발
3. 로그 필터링 기능
### Phase 3: 고급 기능
1. 로그 활성화/비활성화 기능
2. 기존 테이블에 로그 추가 기능
3. 로그 데이터 아카이빙 기능
### Phase 4: 분석 및 최적화
1. 로그 분석 대시보드
2. 성능 최적화
3. 로그 데이터 파티셔닝