diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 7d710110..601e035c 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -24,6 +24,8 @@ export class DashboardController { ): Promise { 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 { 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, diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index bc3e6f52..a5b2f225 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -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({ diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index aac86625..d7b2bd74 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1048,3 +1048,268 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +// ======================================== +// 🎯 테이블 로그 시스템 API +// ======================================== + +/** + * 로그 테이블 생성 + */ +export async function createLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { pkColumn } = req.body; + const userId = req.user?.userId; + + logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) { + const response: ApiResponse = { + 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 = { + success: true, + message: "로그 테이블이 성공적으로 생성되었습니다.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 테이블 생성 중 오류 발생:", error); + + const response: ApiResponse = { + 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 { + try { + const { tableName } = req.params; + + logger.info(`=== 로그 설정 조회: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + 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 = { + success: true, + message: "로그 설정을 조회했습니다.", + data: logConfig, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 설정 조회 중 오류 발생:", error); + + const response: ApiResponse = { + 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 { + 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 = { + 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 = { + success: true, + message: "로그 데이터를 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 데이터 조회 중 오류 발생:", error); + + const response: ApiResponse = { + 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 { + try { + const { tableName } = req.params; + const { isActive } = req.body; + + logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (isActive === undefined || isActive === null) { + const response: ApiResponse = { + 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 = { + success: true, + message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 테이블 토글 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 테이블 토글 중 오류가 발생했습니다.", + error: { + code: "LOG_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index c0b35b94..5e5ddf38 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -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; diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index c7650df2..68cc582f 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -18,7 +18,8 @@ export class DashboardService { */ static async createDashboard( data: CreateDashboardRequest, - userId: string + userId: string, + companyCode?: string ): Promise { 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 { 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( diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 331f980e..999ea6d2 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -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 + data: Record, + ipAddress?: string ): Promise { try { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { @@ -432,7 +433,19 @@ export class DynamicFormService { console.log("📝 실행할 UPSERT SQL:", upsertQuery); console.log("📊 SQL 파라미터:", values); - const result = await query(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); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 83f3a696..10de1e73 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3118,4 +3118,410 @@ export class TableManagementService { // 기본값 return "text"; } + + // ======================================== + // 🎯 테이블 로그 시스템 + // ======================================== + + /** + * 로그 테이블 생성 + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + userId?: string + ): Promise { + 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(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(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 { + 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; + } + } } diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index d1ca6125..55f1a291 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [error, setError] = useState(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 ( -
-
-
로딩 중...
-
대시보드 목록을 불러오고 있습니다
-
-
- ); - } - return ( -
-
- {/* 헤더 */} -
-

대시보드 관리

-

대시보드를 생성하고 관리할 수 있습니다

-
- - {/* 액션 바 */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9" - /> +
+
+ {/* 페이지 제목 */} +
+
+

대시보드 관리

+

대시보드를 생성하고 관리할 수 있습니다

-
- {/* 에러 메시지 */} - {error && ( - -

{error}

+ {/* 검색 및 필터 */} + + +
+
+ + setSearchTerm(e.target.value)} + className="w-64 pl-10" + /> +
+ +
+
+
+ + {/* 대시보드 목록 */} + {loading ? ( +
+
로딩 중...
+
+ ) : dashboards.length === 0 ? ( + + +
+ +

등록된 대시보드가 없습니다

+

첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요.

+ +
+
+
+ ) : ( + + + + + + 제목 + 설명 + 생성일 + 작업 + + + + {dashboards.map((dashboard) => ( + + +
{dashboard.title}
+
+ + {dashboard.description || "-"} + + {formatDate(dashboard.createdAt)} + + + + + + +
+ + + +
+
+
+
+
+ ))} +
+
+
)} - {/* 대시보드 목록 */} - {dashboards.length === 0 ? ( - -
- -
-

대시보드가 없습니다

-

첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요

- -
- ) : ( - - - - - 제목 - 설명 - 생성일 - 수정일 - 작업 - - - - {dashboards.map((dashboard) => ( - - {dashboard.title} - - {dashboard.description || "-"} - - {formatDate(dashboard.createdAt)} - {formatDate(dashboard.updatedAt)} - - - - - - - router.push(`/admin/dashboard/edit/${dashboard.id}`)} - className="gap-2" - > - - 편집 - - handleCopy(dashboard)} className="gap-2"> - - 복사 - - handleDeleteClick(dashboard.id, dashboard.title)} - className="gap-2 text-red-600 focus:text-red-600" - > - - 삭제 - - - - - - ))} - -
-
+ {/* 페이지네이션 */} + {!loading && dashboards.length > 0 && ( + )}
@@ -241,36 +296,24 @@ export default function DashboardListPage() { - 대시보드 삭제 + 대시보드 삭제 확인 "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. +
+ 이 작업은 되돌릴 수 없습니다.
취소 - + 삭제
- - {/* 성공 모달 */} - - - -
- -
- 완료 - {successMessage} -
-
- -
-
-
); } diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index e415fec8..74fd30af 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -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(""); + // 최고 관리자 여부 확인 (회사코드가 "*"인 경우) const isSuperAdmin = user?.companyCode === "*"; @@ -645,15 +650,30 @@ export default function TableManagementPage() { onClick={() => handleTableSelect(table.tableName)} >
-
+

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}

- - {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")} - +
+ + {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")} + + +
)) @@ -972,6 +992,9 @@ export default function TableManagementPage() { /> setDdlLogViewerOpen(false)} /> + + {/* 테이블 로그 뷰어 */} + )}
diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index 7e075ad1..c31482dc 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -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(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 &&

{tableNameError}

} + {tableNameError &&

{tableNameError}

}

영문자로 시작, 영문자/숫자/언더스코어만 사용 가능

@@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
+ {/* 로그 테이블 생성 옵션 */} +
+ setUseLogTable(checked as boolean)} + disabled={loading} + /> +
+ +

+ 선택 시 {tableName || "table"}_log 테이블이 + 자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다. +

+
+
+ {/* 자동 추가 컬럼 안내 */} diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx new file mode 100644 index 00000000..6b899bf6 --- /dev/null +++ b/frontend/components/admin/TableLogViewer.tsx @@ -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([]); + 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(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [changedBy, setChangedBy] = useState(""); + const [originalId, setOriginalId] = useState(""); + + // 로그 데이터 로드 + 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 추가; + case "UPDATE": + return 수정; + case "DELETE": + return 삭제; + default: + return {type}; + } + }; + + // 날짜 포맷팅 + 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 ( + + + + + + {tableName} - 변경 이력 + + + + {/* 필터 영역 */} +
+
+

+ + 필터 +

+ +
+ +
+
+ + +
+ +
+ + setStartDate(e.target.value)} /> +
+ +
+ + setEndDate(e.target.value)} /> +
+ +
+ + setChangedBy(e.target.value)} /> +
+ +
+ + setOriginalId(e.target.value)} /> +
+ +
+ +
+
+
+ + {/* 로그 테이블 */} +
+ {loading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
변경 이력이 없습니다.
+ ) : ( + + + + 작업 + 원본 ID + 변경 컬럼 + 변경 전 + 변경 후 + 변경자 + 변경 시각 + IP + + + + {logs.map((log) => ( + + {getOperationBadge(log.operation_type)} + {log.original_id} + {log.changed_column || "-"} + + {log.old_value || "-"} + + + {log.new_value || "-"} + + {log.changed_by || "system"} + {formatDate(log.changed_at)} + {log.ip_address || "-"} + + ))} + +
+ )} +
+ + {/* 페이지네이션 */} +
+
+ 전체 {total}건 (페이지 {page} / {totalPages}) +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 7b5453f9..a4b0fc6f 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -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} > {/* 헤더 */} -
+
{element.customTitle || element.title}
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */} {onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && ( - + + )} {/* 삭제 버튼 */} - + +
diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 6aba88db..b168fb2c 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -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

{element.title} 설정

-

- {isSimpleWidget - ? "데이터 소스를 설정하세요" - : currentStep === 1 - ? "데이터 소스를 선택하세요" - : "쿼리를 실행하고 차트를 설정하세요"} -

{/* 헤더 표시 옵션 */} @@ -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" />
)}
)} @@ -376,4 +373,3 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element ); } - diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 092c2b0a..9a5a51a6 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -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 (
-
📊
데이터를 설정해주세요
-
⚙️ 버튼을 클릭하여 설정
); diff --git a/frontend/components/admin/dashboard/widgets/ClockSettings.tsx b/frontend/components/admin/dashboard/widgets/ClockSettings.tsx index dd28c3af..43a452fe 100644 --- a/frontend/components/admin/dashboard/widgets/ClockSettings.tsx +++ b/frontend/components/admin/dashboard/widgets/ClockSettings.tsx @@ -44,9 +44,9 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
{[ - { value: "digital", label: "디지털", icon: "🔢" }, - { value: "analog", label: "아날로그", icon: "🕐" }, - { value: "both", label: "둘 다", icon: "⏰" }, + { value: "digital", label: "디지털" }, + { value: "analog", label: "아날로그" }, + { value: "both", label: "둘 다" }, ].map((style) => ( ))} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index d55e8ad3..29c15ca9 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -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; } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx index ead548f1..a4dab504 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx @@ -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 | null; + data_binding?: Record | 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 && ( -
+

{layoutName}

)} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index b6e0139b..81aab11b 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -401,7 +401,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { onLogout={handleLogout} /> -
+
{/* 모바일 사이드바 오버레이 */} {sidebarOpen && isMobile && (
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" && ( diff --git a/frontend/components/layout/MainHeader.tsx b/frontend/components/layout/MainHeader.tsx index cfad594e..f04dcca3 100644 --- a/frontend/components/layout/MainHeader.tsx +++ b/frontend/components/layout/MainHeader.tsx @@ -14,7 +14,7 @@ interface MainHeaderProps { */ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) { return ( -
+
{/* Left side - Side Menu + Logo */}
diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 5dc1cc0a..6a8363ba 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -211,6 +211,114 @@ class TableManagementApi { }; } } + + // ======================================== + // 테이블 로그 시스템 API + // ======================================== + + /** + * 로그 테이블 생성 + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + ): Promise> { + 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> { + 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, + }; + } + } } // 싱글톤 인스턴스 생성 diff --git a/테이블_변경_이력_로그_시스템_구현_계획서.md b/테이블_변경_이력_로그_시스템_구현_계획서.md new file mode 100644 index 00000000..e7f43773 --- /dev/null +++ b/테이블_변경_이력_로그_시스템_구현_계획서.md @@ -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 { + 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 { + 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 { + 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 { + 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 => { + 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(false); + + return ( +
+ {/* 기존 폼 필드들 */} + + {/* 로그 테이블 옵션 추가 */} +
+ +

+ 체크 시 데이터 변경 이력을 기록하는 로그 테이블이 자동으로 생성됩니다. + (테이블명: {tableName}_log) +

+
+ + {useLogTable && ( +
+

로그 테이블 정보

+
    +
  • • INSERT/UPDATE/DELETE 작업이 자동으로 기록됩니다
  • +
  • • 변경 전후 값과 변경자 정보가 저장됩니다
  • +
  • + • 로그는 별도 테이블에 저장되어 원본 테이블 성능에 영향을 + 최소화합니다 +
  • +
+
+ )} +
+ ); +}; +``` + +### 5.2 로그 조회 화면 추가 + +**파일**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx` + +```typescript +interface TableLogViewerProps { + tableName: string; +} + +const TableLogViewer: React.FC = ({ tableName }) => { + const [logs, setLogs] = useState([]); + 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 ( +
+

변경 이력 조회: {tableName}

+ + {/* 필터 */} +
+ + + {/* 날짜 필터, 사용자 필터 등 */} +
+ + {/* 로그 테이블 */} + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + ))} + +
작업유형원본ID변경컬럼변경전변경후변경자변경시각
{log.operation_type}{log.original_id}{log.changed_column}{log.old_value}{log.new_value}{log.changed_by}{log.changed_at}
+
+ ); +}; +``` + +## 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. 로그 데이터 파티셔닝