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/riskAlertCacheService.ts b/backend-node/src/services/riskAlertCacheService.ts index cc4de181..ce8b6089 100644 --- a/backend-node/src/services/riskAlertCacheService.ts +++ b/backend-node/src/services/riskAlertCacheService.ts @@ -34,16 +34,35 @@ export class RiskAlertCacheService { */ public startAutoRefresh(): void { console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)'); + console.log(' - 기상특보: 즉시 호출'); + console.log(' - 교통사고/도로공사: 10분 후 첫 호출'); - // 즉시 첫 갱신 - this.refreshCache(); + // 기상특보만 즉시 호출 (ITS API는 10분 후부터) + this.refreshWeatherOnly(); - // 10분마다 갱신 (600,000ms) + // 10분마다 전체 갱신 (600,000ms) this.updateInterval = setInterval(() => { this.refreshCache(); }, 10 * 60 * 1000); } + /** + * 기상특보만 갱신 (재시작 시 사용) + */ + private async refreshWeatherOnly(): Promise { + try { + console.log('🌤️ 기상특보만 즉시 갱신 중...'); + const weatherAlerts = await this.riskAlertService.getWeatherAlerts(); + + this.cachedAlerts = weatherAlerts; + this.lastUpdated = new Date(); + + console.log(`✅ 기상특보 갱신 완료! (${weatherAlerts.length}건)`); + } catch (error: any) { + console.error('❌ 기상특보 갱신 실패:', error.message); + } + } + /** * 자동 갱신 중지 */ 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 d5608e76..63afe07d 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,35 +158,33 @@ export default function DashboardListPage() { year: "numeric", month: "2-digit", day: "2-digit", - hour: "2-digit", - minute: "2-digit", }); }; if (loading) { return ( -
+
로딩 중...
-
대시보드 목록을 불러오고 있습니다
+
대시보드 목록을 불러오고 있습니다
); } return ( -
+
{/* 페이지 헤더 */}

대시보드 관리

-

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

+

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

{/* 검색 및 액션 */}
- +
{/* 에러 메시지 */} {error && ( -
+
-

오류가 발생했습니다

+

오류가 발생했습니다

-

{error}

+

{error}

)} {/* 대시보드 목록 */} {dashboards.length === 0 ? ( -
+
-

대시보드가 없습니다

+

대시보드가 없습니다

) : ( -
+
- + 제목 설명 생성일 @@ -199,13 +235,17 @@ export default function DashboardListPage() { {dashboards.map((dashboard) => ( - + {dashboard.title} - + {dashboard.description || "-"} - {formatDate(dashboard.createdAt)} - {formatDate(dashboard.updatedAt)} + + {formatDate(dashboard.createdAt)} + + + {formatDate(dashboard.updatedAt)} + @@ -227,7 +267,7 @@ export default function DashboardListPage() { handleDeleteClick(dashboard.id, dashboard.title)} - className="gap-2 text-sm text-destructive focus:text-destructive" + className="text-destructive focus:text-destructive gap-2 text-sm" > 삭제 @@ -241,6 +281,17 @@ export default function DashboardListPage() {
)} + + {/* 페이지네이션 */} + {!loading && dashboards.length > 0 && ( + + )}
{/* 삭제 확인 모달 */} @@ -250,15 +301,14 @@ export default function DashboardListPage() { 대시보드 삭제 "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
- 이 작업은 되돌릴 수 없습니다. +
이 작업은 되돌릴 수 없습니다.
취소 삭제 @@ -270,8 +320,8 @@ export default function DashboardListPage() { -
- +
+
완료 {successMessage} diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index f3fb6be6..c8caa2df 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -18,6 +18,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"; import { ScrollToTop } from "@/components/common/ScrollToTop"; interface TableInfo { @@ -74,6 +75,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 === "*"; @@ -539,7 +544,7 @@ export default function TableManagementPage() { }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); return ( -
+
{/* 페이지 헤더 */}
@@ -548,11 +553,14 @@ export default function TableManagementPage() {

{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}

-

- {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")} +

+ {getTextFromUI( + TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, + "데이터베이스 테이블과 컬럼의 타입을 관리합니다", + )}

{isSuperAdmin && ( -

+

최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다

)} @@ -571,20 +579,33 @@ export default function TableManagementPage() { {selectedTable && ( - )} - )} - @@ -597,13 +618,13 @@ export default function TableManagementPage() {

- + {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}

{/* 검색 */}
- + - + {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
) : tables.length === 0 ? ( -
+
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
) : ( @@ -635,19 +656,17 @@ export default function TableManagementPage() { .map((table) => (
handleTableSelect(table.tableName)} >

{table.displayName || table.tableName}

-

+

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

- 컬럼 + 컬럼 {table.columnCount} @@ -663,267 +682,278 @@ export default function TableManagementPage() {

- + {selectedTable ? <>테이블 설정 - {selectedTable} : "테이블 타입 관리"}

- {!selectedTable ? ( -
-
-

- {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} -

-
-
- ) : ( - <> - {/* 테이블 라벨 설정 */} -
-
- setTableLabel(e.target.value)} - placeholder="테이블 표시명" - className="h-10 text-sm" - /> -
-
- setTableDescription(e.target.value)} - placeholder="테이블 설명" - className="h-10 text-sm" - /> + {!selectedTable ? ( +
+
+

+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} +

- - {columnsLoading ? ( -
- - - {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} - -
- ) : columns.length === 0 ? ( -
- {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} -
- ) : ( -
- {/* 컬럼 헤더 */} -
-
컬럼명
-
라벨
-
입력 타입
-
- 상세 설정 -
-
설명
+ ) : ( + <> + {/* 테이블 라벨 설정 */} +
+
+ setTableLabel(e.target.value)} + placeholder="테이블 표시명" + className="h-10 text-sm" + />
+
+ setTableDescription(e.target.value)} + placeholder="테이블 설명" + className="h-10 text-sm" + /> +
+
- {/* 컬럼 리스트 */} -
{ - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 - if (scrollHeight - scrollTop <= clientHeight + 100) { - loadMoreColumns(); - } - }} - > - {columns.map((column, index) => ( -
-
-
{column.columnName}
-
-
- handleLabelChange(column.columnName, e.target.value)} - placeholder={column.columnName} - className="h-8 text-xs" - /> -
-
- -
-
- {/* 웹 타입이 'code'인 경우 공통코드 선택 */} - {column.inputType === "code" && ( + {columnsLoading ? ( +
+ + + {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} + +
+ ) : columns.length === 0 ? ( +
+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} +
+ ) : ( +
+ {/* 컬럼 헤더 */} +
+
컬럼명
+
라벨
+
입력 타입
+
+ 상세 설정 +
+
설명
+
+ + {/* 컬럼 리스트 */} +
{ + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 + if (scrollHeight - scrollTop <= clientHeight + 100) { + loadMoreColumns(); + } + }} + > + {columns.map((column, index) => ( +
+
+
{column.columnName}
+
+
+ handleLabelChange(column.columnName, e.target.value)} + placeholder={column.columnName} + className="h-8 text-xs" + /> +
+
- )} - {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} - {column.inputType === "entity" && ( -
- {/* Entity 타입 설정 - 가로 배치 */} -
-
- Entity 설정 -
- -
- {/* 참조 테이블 */} -
- - +
+
+ {/* 웹 타입이 'code'인 경우 공통코드 선택 */} + {column.inputType === "code" && ( + + )} + {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} + {column.inputType === "entity" && ( +
+ {/* Entity 타입 설정 - 가로 배치 */} +
+
+ Entity 설정
- {/* 조인 컬럼 */} - {column.referenceTable && column.referenceTable !== "none" && ( +
+ {/* 참조 테이블 */}
- +
- )} + + {/* 조인 컬럼 */} + {column.referenceTable && column.referenceTable !== "none" && ( +
+ + +
+ )} +
+ + {/* 설정 완료 표시 - 간소화 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && + column.displayColumn && + column.displayColumn !== "none" && ( +
+ + + {column.columnName} → {column.referenceTable}.{column.displayColumn} + +
+ )}
- - {/* 설정 완료 표시 - 간소화 */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( -
- - - {column.columnName} → {column.referenceTable}.{column.displayColumn} - -
- )}
-
- )} - {/* 다른 웹 타입인 경우 빈 공간 */} - {column.inputType !== "code" && column.inputType !== "entity" && ( -
-
- )} + )} + {/* 다른 웹 타입인 경우 빈 공간 */} + {column.inputType !== "code" && column.inputType !== "entity" && ( +
-
+ )} +
+
+ handleColumnChange(index, "description", e.target.value)} + placeholder="설명" + className="h-8 text-xs" + /> +
-
- handleColumnChange(index, "description", e.target.value)} - placeholder="설명" - className="h-8 text-xs" - /> -
-
- ))} -
- - {/* 로딩 표시 */} - {columnsLoading && ( -
- - 더 많은 컬럼 로딩 중... + ))}
- )} - {/* 페이지 정보 */} -
- {columns.length} / {totalColumns} 컬럼 표시됨 -
+ {/* 로딩 표시 */} + {columnsLoading && ( +
+ + 더 많은 컬럼 로딩 중... +
+ )} - {/* 전체 저장 버튼 */} -
- + {/* 페이지 정보 */} +
+ {columns.length} / {totalColumns} 컬럼 표시됨 +
+ + {/* 전체 저장 버튼 */} +
+ +
-
- )} - - )} + )} + + )}
@@ -967,6 +997,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..55d45480 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"), { @@ -127,10 +129,17 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C interface CanvasElementProps { element: DashboardElement; isSelected: boolean; + selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열 + allElements?: DashboardElement[]; // 🔥 모든 요소 배열 + multiDragOffset?: { x: number; y: number }; // 🔥 다중 드래그 시 이 요소의 오프셋 cellSize: number; subGridSize: number; canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; + onUpdateMultiple?: (updates: { id: string; updates: Partial }[]) => void; // 🔥 다중 업데이트 + onMultiDragStart?: (draggedId: string, otherOffsets: Record) => void; // 🔥 다중 드래그 시작 + onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중 + onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료 onRemove: (id: string) => void; onSelect: (id: string | null) => void; onConfigure?: (element: DashboardElement) => void; @@ -145,10 +154,17 @@ interface CanvasElementProps { export function CanvasElement({ element, isSelected, + selectedElements = [], + allElements = [], + multiDragOffset, cellSize, subGridSize, canvasWidth = 1560, onUpdate, + onUpdateMultiple, + onMultiDragStart, + onMultiDragMove, + onMultiDragEnd, onRemove, onSelect, onConfigure, @@ -156,6 +172,10 @@ export function CanvasElement({ const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 }); + const dragStartRef = useRef({ x: 0, y: 0, elementX: 0, elementY: 0, initialScrollY: 0 }); // 🔥 스크롤 조정용 ref + const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향 + const autoScrollFrameRef = useRef(null); // 🔥 requestAnimationFrame ID + const lastMouseYRef = useRef(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간) const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, @@ -197,15 +217,39 @@ export function CanvasElement({ } setIsDragging(true); - setDragStart({ + const startPos = { x: e.clientX, y: e.clientY, elementX: element.position.x, elementY: element.position.y, - }); + initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치 + }; + setDragStart(startPos); + dragStartRef.current = startPos; // 🔥 ref에도 저장 + + // 🔥 드래그 시작 시 마우스 위치 초기화 (화면 중간) + lastMouseYRef.current = window.innerHeight / 2; + + // 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산 + if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) { + const offsets: Record = {}; + selectedElements.forEach((id) => { + if (id !== element.id) { + const targetElement = allElements.find((el) => el.id === id); + if (targetElement) { + offsets[id] = { + x: targetElement.position.x - element.position.x, + y: targetElement.position.y - element.position.y, + }; + } + } + }); + onMultiDragStart(element.id, offsets); + } + e.preventDefault(); }, - [element.id, element.position.x, element.position.y, onSelect, isSelected], + [element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart], ); // 리사이즈 핸들 마우스다운 @@ -235,8 +279,25 @@ export function CanvasElement({ const handleMouseMove = useCallback( (e: MouseEvent) => { if (isDragging) { - const deltaX = e.clientX - dragStart.x; - const deltaY = e.clientY - dragStart.y; + // 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리 + const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id; + + if (isFirstSelectedElement) { + const scrollThreshold = 100; + const viewportHeight = window.innerHeight; + const mouseY = e.clientY; + + // 🔥 항상 마우스 위치 업데이트 + lastMouseYRef.current = mouseY; + // console.log("🖱️ 마우스 위치 업데이트:", { mouseY, viewportHeight, top: scrollThreshold, bottom: viewportHeight - scrollThreshold }); + } + + // 🔥 현재 스크롤 위치를 고려한 deltaY 계산 + const currentScrollY = window.pageYOffset; + const scrollDelta = currentScrollY - dragStartRef.current.initialScrollY; + + const deltaX = e.clientX - dragStartRef.current.x; + const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영 // 임시 위치 계산 let rawX = Math.max(0, dragStart.elementX + deltaX); @@ -246,21 +307,16 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // 드래그 중 실시간 스냅 (마그네틱 스냅) - const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 - const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) - - // X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) - const nearestGridX = Math.round(rawX / gridSize) * gridSize; - const distToGridX = Math.abs(rawX - nearestGridX); - const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; - - // Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) - const nearestGridY = Math.round(rawY / gridSize) * gridSize; - const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; + // 드래그 중 실시간 스냅 (서브그리드만 사용) + const snappedX = Math.round(rawX / subGridSize) * subGridSize; + const snappedY = Math.round(rawY / subGridSize) * subGridSize; setTempPosition({ x: snappedX, y: snappedY }); + + // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 + if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { + onMultiDragMove(element, { x: snappedX, y: snappedY }); + } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; @@ -303,35 +359,11 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 리사이즈 중 실시간 스냅 (마그네틱 스냅) - const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 - const magneticThreshold = 15; - - // 위치 스냅 - const nearestGridX = Math.round(newX / gridSize) * gridSize; - const distToGridX = Math.abs(newX - nearestGridX); - const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize; - - const nearestGridY = Math.round(newY / gridSize) * gridSize; - const distToGridY = Math.abs(newY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize; - - // 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외) - // 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2 - const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5; - - // 가장 가까운 그리드 칸 수 계산 - const nearestWidthCells = Math.round(newWidth / gridSize); - const nearestGridWidth = calculateGridWidth(nearestWidthCells); - const distToGridWidth = Math.abs(newWidth - nearestGridWidth); - const snappedWidth = - distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize; - - const nearestHeightCells = Math.round(newHeight / gridSize); - const nearestGridHeight = calculateGridWidth(nearestHeightCells); - const distToGridHeight = Math.abs(newHeight - nearestGridHeight); - const snappedHeight = - distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize; + // 리사이즈 중 실시간 스냅 (서브그리드만 사용) + const snappedX = Math.round(newX / subGridSize) * subGridSize; + const snappedY = Math.round(newY / subGridSize) * subGridSize; + const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize; + const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize; // 임시 크기/위치 저장 (스냅됨) setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); @@ -343,12 +375,15 @@ export function CanvasElement({ isResizing, dragStart, resizeStart, - element.size.width, - element.type, - element.subtype, + element, canvasWidth, cellSize, subGridSize, + selectedElements, + allElements, + onUpdateMultiple, + onMultiDragMove, + // dragStartRef, autoScrollDirectionRef, autoScrollFrameRef는 ref라서 dependency 불필요 ], ); @@ -368,7 +403,42 @@ export function CanvasElement({ position: { x: finalX, y: finalY }, }); + // 🔥 다중 선택된 요소들도 함께 업데이트 + if (selectedElements.length > 1 && selectedElements.includes(element.id) && onUpdateMultiple) { + const updates = selectedElements + .filter((id) => id !== element.id) // 현재 요소 제외 + .map((id) => { + const targetElement = allElements.find((el) => el.id === id); + if (!targetElement) return null; + + // 현재 요소와의 상대적 위치 유지 + const relativeX = targetElement.position.x - dragStart.elementX; + const relativeY = targetElement.position.y - dragStart.elementY; + + return { + id, + updates: { + position: { + x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)), + y: Math.max(0, finalY + relativeY), + }, + }, + }; + }) + .filter((update): update is { id: string; updates: Partial } => update !== null); + + if (updates.length > 0) { + // console.log("🔥 다중 선택 요소 함께 이동:", updates); + onUpdateMultiple(updates); + } + } + setTempPosition(null); + + // 🔥 다중 드래그 종료 + if (onMultiDragEnd) { + onMultiDragEnd(); + } } if (isResizing && tempPosition && tempSize) { @@ -394,7 +464,84 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); - }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]); + + // 🔥 자동 스크롤 정리 + autoScrollDirectionRef.current = null; + if (autoScrollFrameRef.current) { + cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + } + }, [ + isDragging, + isResizing, + tempPosition, + tempSize, + element.id, + element.size.width, + onUpdate, + onUpdateMultiple, + onMultiDragEnd, + cellSize, + canvasWidth, + selectedElements, + allElements, + dragStart.elementX, + dragStart.elementY, + ]); + + // 🔥 자동 스크롤 루프 (requestAnimationFrame 사용) + useEffect(() => { + if (!isDragging) return; + + const scrollSpeed = 3; // 🔥 속도를 좀 더 부드럽게 (5 → 3) + const scrollThreshold = 100; + let animationFrameId: number; + let lastTime = performance.now(); + + const autoScrollLoop = (currentTime: number) => { + const viewportHeight = window.innerHeight; + const lastMouseY = lastMouseYRef.current; + + // 🔥 스크롤 방향 결정 + let shouldScroll = false; + let scrollDirection = 0; + + if (lastMouseY < scrollThreshold) { + // 위쪽 영역 + shouldScroll = true; + scrollDirection = -scrollSpeed; + // console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold }); + } else if (lastMouseY > viewportHeight - scrollThreshold) { + // 아래쪽 영역 + shouldScroll = true; + scrollDirection = scrollSpeed; + // console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold }); + } + + // 🔥 프레임 간격 계산 + const deltaTime = currentTime - lastTime; + + // 🔥 10ms 간격으로 스크롤 + if (shouldScroll && deltaTime >= 10) { + window.scrollBy(0, scrollDirection); + // console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime }); + lastTime = currentTime; + } + + // 계속 반복 + animationFrameId = requestAnimationFrame(autoScrollLoop); + }; + + // 루프 시작 + animationFrameId = requestAnimationFrame(autoScrollLoop); + autoScrollFrameRef.current = animationFrameId; + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + }, [isDragging]); // 전역 마우스 이벤트 등록 React.useEffect(() => { @@ -523,12 +670,17 @@ export function CanvasElement({ }; // 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용 - const displayPosition = tempPosition || element.position; + // 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선) + const displayPosition = tempPosition || (multiDragOffset && !isDragging ? { + x: element.position.x + multiDragOffset.x, + y: element.position.y + multiDragOffset.y, + } : element.position); const displaySize = tempSize || element.size; return (
{/* 헤더 */} -
+
{element.customTitle || element.title}
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */} {onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && ( - + + )} {/* 삭제 버튼 */} - + +
diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 3170880a..1c2414c1 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { forwardRef, useState, useCallback, useMemo } from "react"; +import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; @@ -9,10 +9,12 @@ import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { elements: DashboardElement[]; selectedElement: string | null; + selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열 onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void; onUpdateElement: (id: string, updates: Partial) => void; onRemoveElement: (id: string) => void; onSelectElement: (id: string | null) => void; + onSelectMultiple?: (ids: string[]) => void; // 🔥 다중 선택 핸들러 onConfigureElement?: (element: DashboardElement) => void; backgroundColor?: string; canvasWidth?: number; @@ -31,10 +33,12 @@ export const DashboardCanvas = forwardRef( { elements, selectedElement, + selectedElements = [], onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, + onSelectMultiple, onConfigureElement, backgroundColor = "#f9fafb", canvasWidth = 1560, @@ -43,6 +47,24 @@ export const DashboardCanvas = forwardRef( ref, ) => { const [isDragOver, setIsDragOver] = useState(false); + + // 🔥 선택 박스 상태 + const [selectionBox, setSelectionBox] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + } | null>(null); + const [isSelecting, setIsSelecting] = useState(false); + const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그 + const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그 + + // 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백) + const [multiDragOffsets, setMultiDragOffsets] = useState>({}); + + // 🔥 선택 박스 드래그 중 자동 스크롤 + const lastMouseYForSelectionRef = React.useRef(window.innerHeight / 2); + const selectionAutoScrollFrameRef = React.useRef(null); // 현재 캔버스 크기에 맞는 그리드 설정 계산 const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); @@ -182,14 +204,230 @@ export const DashboardCanvas = forwardRef( [ref, onCreateElement, canvasWidth, cellSize], ); + // 🔥 선택 박스 드래그 시작 + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우) + const target = e.target as HTMLElement; + const isWidget = target.closest("[data-element-id]"); + + if (isWidget) { + // console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함"); + return; + } + + // console.log("✅ 빈 공간 클릭 - 선택 박스 시작"); + + if (!ref || typeof ref === "function") return; + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0); + const y = e.clientY - rect.top + (ref.current?.scrollTop || 0); + + // 🔥 일단 시작 위치만 저장 (아직 isSelecting은 false) + setSelectionBox({ startX: x, startY: y, endX: x, endY: y }); + }, + [ref], + ); + + // 🔥 선택 박스 드래그 종료 + const handleMouseUp = useCallback(() => { + if (!isSelecting || !selectionBox) { + setIsSelecting(false); + setSelectionBox(null); + return; + } + + if (!onSelectMultiple) { + setIsSelecting(false); + setSelectionBox(null); + return; + } + + // 선택 박스 영역 계산 + const minX = Math.min(selectionBox.startX, selectionBox.endX); + const maxX = Math.max(selectionBox.startX, selectionBox.endX); + const minY = Math.min(selectionBox.startY, selectionBox.endY); + const maxY = Math.max(selectionBox.startY, selectionBox.endY); + + // console.log("🔍 선택 박스:", { minX, maxX, minY, maxY }); + + // 선택 박스 안에 있는 요소들 찾기 (70% 이상 겹치면 선택) + const selectedIds = elements + .filter((el) => { + const elLeft = el.position.x; + const elRight = el.position.x + el.size.width; + const elTop = el.position.y; + const elBottom = el.position.y + el.size.height; + + // 겹치는 영역 계산 + const overlapLeft = Math.max(elLeft, minX); + const overlapRight = Math.min(elRight, maxX); + const overlapTop = Math.max(elTop, minY); + const overlapBottom = Math.min(elBottom, maxY); + + // 겹치는 영역이 없으면 false + if (overlapRight < overlapLeft || overlapBottom < overlapTop) { + return false; + } + + // 겹치는 영역의 넓이 + const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop); + + // 요소의 전체 넓이 + const elementArea = el.size.width * el.size.height; + + // 70% 이상 겹치면 선택 + const overlapPercentage = overlapArea / elementArea; + + // console.log(`📦 요소 ${el.id}:`, { + // position: el.position, + // size: el.size, + // overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%", + // selected: overlapPercentage >= 0.7, + // }); + + return overlapPercentage >= 0.7; + }) + .map((el) => el.id); + + // console.log("✅ 선택된 요소:", selectedIds); + + if (selectedIds.length > 0) { + onSelectMultiple(selectedIds); + setJustSelected(true); // 🔥 방금 선택했음을 표시 + setTimeout(() => setJustSelected(false), 100); // 100ms 후 플래그 해제 + } else { + onSelectMultiple([]); // 빈 배열도 전달 + } + + setIsSelecting(false); + setSelectionBox(null); + }, [isSelecting, selectionBox, elements, onSelectMultiple]); + + // 🔥 document 레벨에서 마우스 이동/해제 감지 (위젯 위에서도 작동) + useEffect(() => { + if (!selectionBox) return; + + const handleDocumentMouseMove = (e: MouseEvent) => { + if (!ref || typeof ref === "function") return; + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0); + const y = e.clientY - rect.top + (ref.current?.scrollTop || 0); + + // 🔥 자동 스크롤을 위한 마우스 Y 위치 저장 + lastMouseYForSelectionRef.current = e.clientY; + + // console.log("🖱️ 마우스 이동:", { x, y, startX: selectionBox.startX, startY: selectionBox.startY, isSelecting }); + + // 🔥 selectionBox가 있지만 아직 isSelecting이 false인 경우 (드래그 시작 대기) + if (!isSelecting) { + const deltaX = Math.abs(x - selectionBox.startX); + const deltaY = Math.abs(y - selectionBox.startY); + + // console.log("📏 이동 거리:", { deltaX, deltaY }); + + // 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분) + if (deltaX > 5 || deltaY > 5) { + // console.log("🎯 선택 박스 활성화 (5px 이상 이동)"); + setIsSelecting(true); + } + return; + } + + // 🔥 선택 박스 업데이트 + // console.log("📦 선택 박스 업데이트:", { startX: selectionBox.startX, startY: selectionBox.startY, endX: x, endY: y }); + setSelectionBox((prev) => (prev ? { ...prev, endX: x, endY: y } : null)); + }; + + const handleDocumentMouseUp = () => { + // console.log("🖱️ 마우스 업 - handleMouseUp 호출"); + handleMouseUp(); + }; + + document.addEventListener("mousemove", handleDocumentMouseMove); + document.addEventListener("mouseup", handleDocumentMouseUp); + + return () => { + document.removeEventListener("mousemove", handleDocumentMouseMove); + document.removeEventListener("mouseup", handleDocumentMouseUp); + }; + }, [selectionBox, isSelecting, ref, handleMouseUp]); + + // 🔥 선택 박스 드래그 중 자동 스크롤 + useEffect(() => { + if (!isSelecting) { + // console.log("❌ 자동 스크롤 비활성화: isSelecting =", isSelecting); + return; + } + + // console.log("✅ 자동 스크롤 활성화: isSelecting =", isSelecting); + + const scrollSpeed = 3; + const scrollThreshold = 100; + let animationFrameId: number; + let lastTime = performance.now(); + + const autoScrollLoop = (currentTime: number) => { + const viewportHeight = window.innerHeight; + const lastMouseY = lastMouseYForSelectionRef.current; + + let shouldScroll = false; + let scrollDirection = 0; + + if (lastMouseY < scrollThreshold) { + shouldScroll = true; + scrollDirection = -scrollSpeed; + // console.log("⬆️ 위로 스크롤 (선택 박스):", { lastMouseY, scrollThreshold }); + } else if (lastMouseY > viewportHeight - scrollThreshold) { + shouldScroll = true; + scrollDirection = scrollSpeed; + // console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold }); + } + + const deltaTime = currentTime - lastTime; + + if (shouldScroll && deltaTime >= 10) { + window.scrollBy(0, scrollDirection); + // console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime }); + lastTime = currentTime; + } + + animationFrameId = requestAnimationFrame(autoScrollLoop); + }; + + animationFrameId = requestAnimationFrame(autoScrollLoop); + selectionAutoScrollFrameRef.current = animationFrameId; + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + // console.log("🛑 자동 스크롤 정리"); + }; + }, [isSelecting]); + // 캔버스 클릭 시 선택 해제 const handleCanvasClick = useCallback( (e: React.MouseEvent) => { + // 🔥 방금 선택했거나 드래그 중이면 클릭 이벤트 무시 (선택 해제 방지) + if (justSelected || isDraggingAny) { + // console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시"); + return; + } + if (e.target === e.currentTarget) { + // console.log("✅ 빈 공간 클릭 - 선택 해제"); onSelectElement(null); + if (onSelectMultiple) { + onSelectMultiple([]); + } } }, - [onSelectElement], + [onSelectElement, onSelectMultiple, justSelected, isDraggingAny], ); // 동적 그리드 크기 계산 @@ -202,6 +440,23 @@ export const DashboardCanvas = forwardRef( // 12개 컬럼 구분선 위치 계산 const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); + // 🔥 선택 박스 스타일 계산 + const selectionBoxStyle = useMemo(() => { + if (!selectionBox) return null; + + const minX = Math.min(selectionBox.startX, selectionBox.endX); + const maxX = Math.max(selectionBox.startX, selectionBox.endX); + const minY = Math.min(selectionBox.startY, selectionBox.endY); + const maxY = Math.max(selectionBox.startY, selectionBox.endY); + + return { + left: `${minX}px`, + top: `${minY}px`, + width: `${maxX - minX}px`, + height: `${maxY - minY}px`, + }; + }, [selectionBox]); + return (
( backgroundSize: `${subGridSize}px ${subGridSize}px`, backgroundPosition: "0 0", backgroundRepeat: "repeat", + cursor: isSelecting ? "crosshair" : "default", }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={handleCanvasClick} + onMouseDown={handleMouseDown} > - {/* 12개 컬럼 메인 구분선 */} - {columnLines.map((x, i) => ( + {/* 12개 컬럼 메인 구분선 - 주석 처리 (서브그리드만 사용) */} + {/* {columnLines.map((x, i) => (
( zIndex: 0, }} /> - ))} + ))} */} {/* 배치된 요소들 렌더링 */} {elements.length === 0 && (
@@ -249,16 +506,68 @@ export const DashboardCanvas = forwardRef( { + // 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기) + updates.forEach(({ id, updates: elementUpdates }) => { + onUpdateElement(id, elementUpdates); + }); + }} + onMultiDragStart={(draggedId, initialOffsets) => { + // 🔥 다중 드래그 시작 - 초기 오프셋 저장 + setMultiDragOffsets(initialOffsets); + setIsDraggingAny(true); + }} + onMultiDragMove={(draggedElement, tempPosition) => { + // 🔥 다중 드래그 중 - 다른 위젯들의 위치 실시간 업데이트 + if (selectedElements.length > 1 && selectedElements.includes(draggedElement.id)) { + const newOffsets: Record = {}; + selectedElements.forEach((id) => { + if (id !== draggedElement.id) { + const targetElement = elements.find((el) => el.id === id); + if (targetElement) { + const relativeX = targetElement.position.x - draggedElement.position.x; + const relativeY = targetElement.position.y - draggedElement.position.y; + newOffsets[id] = { + x: tempPosition.x + relativeX - targetElement.position.x, + y: tempPosition.y + relativeY - targetElement.position.y, + }; + } + } + }); + setMultiDragOffsets(newOffsets); + } + }} + onMultiDragEnd={() => { + // 🔥 다중 드래그 종료 - 오프셋 초기화 + setMultiDragOffsets({}); + setIsDraggingAny(false); + }} onRemove={onRemoveElement} onSelect={onSelectElement} onConfigure={onConfigureElement} /> ))} + + {/* 🔥 선택 박스 렌더링 */} + {selectionBox && selectionBoxStyle && ( +
+ )}
); }, diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index c0d08083..2d482ab3 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -9,10 +9,11 @@ import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; +import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { AlertDialog, @@ -43,6 +44,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const { refreshMenus } = useMenu(); const [elements, setElements] = useState([]); const [selectedElement, setSelectedElement] = useState(null); + const [selectedElements, setSelectedElements] = useState([]); // 🔥 다중 선택 const [elementCounter, setElementCounter] = useState(0); const [configModalElement, setConfigModalElement] = useState(null); const [dashboardId, setDashboardId] = useState(initialDashboardId || null); @@ -57,6 +59,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [successModalOpen, setSuccessModalOpen] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + // 클립보드 (복사/붙여넣기용) + const [clipboard, setClipboard] = useState(null); + // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); @@ -209,22 +214,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return; } - // 기본 크기 설정 - let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기 + // 기본 크기 설정 (서브그리드 기준) + const gridConfig = calculateGridConfig(canvasConfig.width); + const subGridSize = gridConfig.SUB_GRID_SIZE; + + // 서브그리드 기준 기본 크기 (픽셀) + let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 + let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 if (type === "chart") { - defaultCells = { width: 4, height: 3 }; // 차트 + defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸 + defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸 } else if (type === "widget" && subtype === "calendar") { - defaultCells = { width: 2, height: 3 }; // 달력 최소 크기 + defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸 + defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸 } - // 현재 해상도에 맞는 셀 크기 계산 - const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; - const cellWithGap = cellSize + GRID_CONFIG.GAP; - - const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; - const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; - // 크기 유효성 검사 if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { // console.error("Invalid size calculated:", { @@ -289,6 +294,51 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D [selectedElement], ); + // 키보드 단축키 핸들러들 + const handleCopyElement = useCallback(() => { + if (!selectedElement) return; + const element = elements.find((el) => el.id === selectedElement); + if (element) { + setClipboard(element); + } + }, [selectedElement, elements]); + + const handlePasteElement = useCallback(() => { + if (!clipboard) return; + + // 새 ID 생성 + const newId = `element-${elementCounter + 1}`; + setElementCounter((prev) => prev + 1); + + // 위치를 약간 오프셋 (오른쪽 아래로 20px씩) + const newElement: DashboardElement = { + ...clipboard, + id: newId, + position: { + x: clipboard.position.x + 20, + y: clipboard.position.y + 20, + }, + }; + + setElements((prev) => [...prev, newElement]); + setSelectedElement(newId); + }, [clipboard, elementCounter]); + + const handleDeleteSelected = useCallback(() => { + if (selectedElement) { + removeElement(selectedElement); + } + }, [selectedElement, removeElement]); + + // 키보드 단축키 활성화 + useKeyboardShortcuts({ + selectedElementId: selectedElement, + onDelete: handleDeleteSelected, + onCopy: handleCopyElement, + onPaste: handlePasteElement, + enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen, + }); + // 전체 삭제 확인 모달 열기 const clearCanvas = useCallback(() => { setClearConfirmOpen(true); @@ -504,10 +554,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D ref={canvasRef} elements={elements} selectedElement={selectedElement} + selectedElements={selectedElements} onCreateElement={createElement} onUpdateElement={updateElement} onRemoveElement={removeElement} - onSelectElement={setSelectedElement} + onSelectElement={(id) => { + setSelectedElement(id); + setSelectedElements([]); // 단일 선택 시 다중 선택 해제 + }} + onSelectMultiple={(ids) => { + console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids); + setSelectedElements(ids); + setSelectedElement(null); // 다중 선택 시 단일 선택 해제 + }} onConfigureElement={openConfigModal} backgroundColor={canvasBackgroundColor} canvasWidth={canvasConfig.width} 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/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index 09f45411..2e6616f9 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { ChartDataSource, QueryResult, KeyValuePair } from "../types"; import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Plus, X, Play, AlertCircle } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; interface ApiConfigProps { dataSource: ChartDataSource; @@ -24,6 +26,106 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(null); + const [apiConnections, setApiConnections] = useState([]); + const [selectedConnectionId, setSelectedConnectionId] = useState(""); + + // 외부 API 커넥션 목록 로드 + useEffect(() => { + const loadApiConnections = async () => { + const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" }); + setApiConnections(connections); + }; + loadApiConnections(); + }, []); + + // 외부 커넥션 선택 핸들러 + const handleConnectionSelect = async (connectionId: string) => { + setSelectedConnectionId(connectionId); + + if (!connectionId || connectionId === "manual") return; + + const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId)); + if (!connection) { + console.error("커넥션을 찾을 수 없습니다:", connectionId); + return; + } + + console.log("불러온 커넥션:", connection); + + // 커넥션 설정을 API 설정에 자동 적용 + const updates: Partial = { + endpoint: connection.base_url, + }; + + const headers: KeyValuePair[] = []; + const queryParams: KeyValuePair[] = []; + + // 기본 헤더가 있으면 적용 + if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { + Object.entries(connection.default_headers).forEach(([key, value]) => { + headers.push({ + id: `header_${Date.now()}_${Math.random()}`, + key, + value, + }); + }); + console.log("기본 헤더 적용:", headers); + } + + // 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가 + if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) { + console.log("인증 설정:", connection.auth_type, connection.auth_config); + + if (connection.auth_type === "bearer" && connection.auth_config.token) { + headers.push({ + id: `header_${Date.now()}_auth`, + key: "Authorization", + value: `Bearer ${connection.auth_config.token}`, + }); + console.log("Bearer 토큰 추가"); + } else if (connection.auth_type === "api-key") { + console.log("API Key 설정:", connection.auth_config); + + if (connection.auth_config.keyName && connection.auth_config.keyValue) { + if (connection.auth_config.keyLocation === "header") { + headers.push({ + id: `header_${Date.now()}_apikey`, + key: connection.auth_config.keyName, + value: connection.auth_config.keyValue, + }); + console.log(`API Key 헤더 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`); + } else if (connection.auth_config.keyLocation === "query") { + queryParams.push({ + id: `param_${Date.now()}_apikey`, + key: connection.auth_config.keyName, + value: connection.auth_config.keyValue, + }); + console.log( + `API Key 쿼리 파라미터 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`, + ); + } + } + } else if ( + connection.auth_type === "basic" && + connection.auth_config.username && + connection.auth_config.password + ) { + const basicAuth = btoa(`${connection.auth_config.username}:${connection.auth_config.password}`); + headers.push({ + id: `header_${Date.now()}_basic`, + key: "Authorization", + value: `Basic ${basicAuth}`, + }); + console.log("Basic Auth 추가"); + } + } + + updates.headers = headers; + updates.queryParams = queryParams; + console.log("최종 업데이트:", updates); + + onChange(updates); + }; // 헤더를 배열로 정규화 (객체 형식 호환) const normalizeHeaders = (): KeyValuePair[] => { @@ -217,6 +319,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps

외부 API에서 데이터를 가져올 설정을 입력하세요

+ {/* 외부 커넥션 선택 */} + {apiConnections.length > 0 && ( + +
+ + +

외부 커넥션 관리에서 저장한 REST API 설정을 불러올 수 있습니다

+
+
+ )} + {/* API URL */}
@@ -230,13 +356,6 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps />

GET 요청을 보낼 API 엔드포인트

- - {/* HTTP 메서드 (고정) */} -
- -
GET (고정)
-

데이터 조회는 GET 메서드만 지원합니다

-
{/* 쿼리 파라미터 */} diff --git a/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts b/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..d0fab67f --- /dev/null +++ b/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,105 @@ +import { useEffect, useCallback } from "react"; + +interface KeyboardShortcutsProps { + selectedElementId: string | null; + onDelete: () => void; + onCopy: () => void; + onPaste: () => void; + onUndo?: () => void; + onRedo?: () => void; + enabled?: boolean; +} + +/** + * 대시보드 키보드 단축키 훅 + * + * 지원 단축키: + * - Delete: 선택한 요소 삭제 + * - Ctrl+C: 요소 복사 + * - Ctrl+V: 요소 붙여넣기 + * - Ctrl+Z: 실행 취소 (구현 예정) + * - Ctrl+Shift+Z: 재실행 (구현 예정) + */ +export function useKeyboardShortcuts({ + selectedElementId, + onDelete, + onCopy, + onPaste, + onUndo, + onRedo, + enabled = true, +}: KeyboardShortcutsProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!enabled) return; + + // 입력 필드에서는 단축키 비활성화 + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.contentEditable === "true" || + target.closest('[role="dialog"]') || + target.closest('[role="alertdialog"]') + ) { + return; + } + + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const ctrlKey = isMac ? e.metaKey : e.ctrlKey; + + // Delete: 선택한 요소 삭제 + if (e.key === "Delete" || e.key === "Backspace") { + if (selectedElementId) { + e.preventDefault(); + onDelete(); + } + return; + } + + // Ctrl+C: 복사 + if (ctrlKey && e.key === "c") { + if (selectedElementId) { + e.preventDefault(); + onCopy(); + } + return; + } + + // Ctrl+V: 붙여넣기 + if (ctrlKey && e.key === "v") { + e.preventDefault(); + onPaste(); + return; + } + + // Ctrl+Z: 실행 취소 + if (ctrlKey && e.key === "z" && !e.shiftKey) { + if (onUndo) { + e.preventDefault(); + onUndo(); + } + return; + } + + // Ctrl+Shift+Z 또는 Ctrl+Y: 재실행 + if ((ctrlKey && e.shiftKey && e.key === "z") || (ctrlKey && e.key === "y")) { + if (onRedo) { + e.preventDefault(); + onRedo(); + } + return; + } + }, + [enabled, selectedElementId, onDelete, onCopy, onPaste, onUndo, onRedo], + ); + + useEffect(() => { + if (!enabled) return; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [handleKeyDown, enabled]); +} 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/dashboard/widgets/RiskAlertWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx index 98278fdd..de6b2af8 100644 --- a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx @@ -33,11 +33,11 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { const [lastUpdated, setLastUpdated] = useState(null); const [newAlertIds, setNewAlertIds] = useState>(new Set()); - // 데이터 로드 (백엔드 통합 호출) + // 데이터 로드 (백엔드 캐시 조회) const loadData = async () => { setIsRefreshing(true); try { - // 백엔드 API 호출 (교통사고, 기상특보, 도로공사 통합) + // 백엔드 API 호출 (캐시된 데이터) const response = await apiClient.get<{ success: boolean; data: Alert[]; @@ -79,6 +79,48 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { } }; + // 강제 새로고침 (실시간 API 호출) + const forceRefresh = async () => { + setIsRefreshing(true); + try { + // 강제 갱신 API 호출 (실시간 데이터) + const response = await apiClient.post<{ + success: boolean; + data: Alert[]; + count: number; + message?: string; + }>("/risk-alerts/refresh", {}); + + if (response.data.success && response.data.data) { + const newData = response.data.data; + + // 새로운 알림 감지 + const oldIds = new Set(alerts.map(a => a.id)); + const newIds = new Set(); + newData.forEach(alert => { + if (!oldIds.has(alert.id)) { + newIds.add(alert.id); + } + }); + + setAlerts(newData); + setNewAlertIds(newIds); + setLastUpdated(new Date()); + + // 3초 후 새 알림 애니메이션 제거 + if (newIds.size > 0) { + setTimeout(() => setNewAlertIds(new Set()), 3000); + } + } else { + console.error("❌ 리스크 알림 강제 갱신 실패"); + } + } catch (error: any) { + console.error("❌ 리스크 알림 강제 갱신 오류:", error.message); + } finally { + setIsRefreshing(false); + } + }; + useEffect(() => { loadData(); // 1분마다 자동 새로고침 (60000ms) @@ -156,7 +198,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { {lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })} )} -
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/components/screen/ResponsivePreviewModal.tsx b/frontend/components/screen/ResponsivePreviewModal.tsx index d3f80c3c..1e05a86b 100644 --- a/frontend/components/screen/ResponsivePreviewModal.tsx +++ b/frontend/components/screen/ResponsivePreviewModal.tsx @@ -3,7 +3,7 @@ import React, { useState, createContext, useContext } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Monitor, Tablet, Smartphone, X } from "lucide-react"; +import { Monitor, Tablet, Smartphone } from "lucide-react"; import { ComponentData } from "@/types/screen"; import { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine"; import { Breakpoint } from "@/types/responsive"; @@ -76,12 +76,7 @@ export const ResponsivePreviewModal: React.FC = ({ -
- 반응형 미리보기 - -
+ 반응형 미리보기 {/* 디바이스 선택 버튼들 */}
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 358b82fd..3d1c19a3 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -420,331 +420,367 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD timestamp: new Date().toISOString(), }); - const targetComponent = layout.components.find((comp) => comp.id === componentId); - const isLayoutComponent = targetComponent?.type === "layout"; + // 🔥 함수형 업데이트로 변경하여 최신 layout 사용 + setLayout((prevLayout) => { + const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); + const isLayoutComponent = targetComponent?.type === "layout"; - // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 - const positionDelta = { x: 0, y: 0 }; - if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { - const oldPosition = targetComponent.position; - let newPosition = { ...oldPosition }; + // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 + const positionDelta = { x: 0, y: 0 }; + if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { + const oldPosition = targetComponent.position; + let newPosition = { ...oldPosition }; - if (path === "position.x") { - newPosition.x = value; - positionDelta.x = value - oldPosition.x; - } else if (path === "position.y") { - newPosition.y = value; - positionDelta.y = value - oldPosition.y; - } else if (path === "position") { - newPosition = value; - positionDelta.x = value.x - oldPosition.x; - positionDelta.y = value.y - oldPosition.y; + if (path === "position.x") { + newPosition.x = value; + positionDelta.x = value - oldPosition.x; + } else if (path === "position.y") { + newPosition.y = value; + positionDelta.y = value - oldPosition.y; + } else if (path === "position") { + newPosition = value; + positionDelta.x = value.x - oldPosition.x; + positionDelta.y = value.y - oldPosition.y; + } + + console.log("📐 레이아웃 이동 감지:", { + layoutId: componentId, + oldPosition, + newPosition, + positionDelta, + }); } - console.log("📐 레이아웃 이동 감지:", { - layoutId: componentId, - oldPosition, - newPosition, - positionDelta, - }); - } + const pathParts = path.split("."); + const updatedComponents = prevLayout.components.map((comp) => { + if (comp.id !== componentId) { + // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 + if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { + // 이 레이아웃의 존에 속한 컴포넌트인지 확인 + const isInLayoutZone = comp.parentId === componentId && comp.zoneId; + if (isInLayoutZone) { + console.log("🔄 존 컴포넌트 함께 이동:", { + componentId: comp.id, + zoneId: comp.zoneId, + oldPosition: comp.position, + delta: positionDelta, + }); - const pathParts = path.split("."); - const updatedComponents = layout.components.map((comp) => { - if (comp.id !== componentId) { - // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 - if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { - // 이 레이아웃의 존에 속한 컴포넌트인지 확인 - const isInLayoutZone = comp.parentId === componentId && comp.zoneId; - if (isInLayoutZone) { - console.log("🔄 존 컴포넌트 함께 이동:", { - componentId: comp.id, - zoneId: comp.zoneId, - oldPosition: comp.position, - delta: positionDelta, - }); - - return { - ...comp, - position: { - ...comp.position, - x: comp.position.x + positionDelta.x, - y: comp.position.y + positionDelta.y, - }, - }; + return { + ...comp, + position: { + ...comp.position, + x: comp.position.x + positionDelta.x, + y: comp.position.y + positionDelta.y, + }, + }; + } } + return comp; } - return comp; - } - // 중첩 경로를 고려한 안전한 복사 - const newComp = { ...comp }; + // 중첩 경로를 고려한 안전한 복사 + const newComp = { ...comp }; - // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 - let current: any = newComp; - for (let i = 0; i < pathParts.length - 1; i++) { - const key = pathParts[i]; - // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 - if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { - current[key] = {}; - } else { - // 기존 객체를 복사하여 불변성 유지 - current[key] = { ...current[key] }; + console.log("🔍 업데이트 전 상태:", { + path, + value, + "기존 componentConfig": newComp.componentConfig, + "기존 action": (newComp as any).componentConfig?.action, + }); + + // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 + let current: any = newComp; + for (let i = 0; i < pathParts.length - 1; i++) { + const key = pathParts[i]; + console.log(`🔍 경로 탐색 [${i}]: key="${key}", current[key]=`, current[key]); + + // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 + if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { + console.log(`🆕 새 객체 생성: ${key}`); + current[key] = {}; + } else { + // 기존 객체를 복사하여 불변성 유지 + console.log(`📋 기존 객체 복사: ${key}`, { ...current[key] }); + current[key] = { ...current[key] }; + } + current = current[key]; } - current = current[key]; - } - // 최종 값 설정 - current[pathParts[pathParts.length - 1]] = value; + // 최종 값 설정 + const finalKey = pathParts[pathParts.length - 1]; + console.log(`✍️ 최종 값 설정: ${finalKey} = ${value}`); + current[finalKey] = value; - console.log("✅ 컴포넌트 업데이트 완료:", { - componentId, - path, - newValue: current[pathParts[pathParts.length - 1]], - fullComponent: newComp, - webTypeConfig: newComp.type === "widget" ? (newComp as any).webTypeConfig : null, - }); - - // webTypeConfig 업데이트의 경우 추가 검증 - if (path === "webTypeConfig") { - console.log("🎛️ webTypeConfig 특별 처리:", { - componentId, - oldConfig: comp.type === "widget" ? (comp as any).webTypeConfig : null, - newConfig: current[pathParts[pathParts.length - 1]], - configType: typeof current[pathParts[pathParts.length - 1]], - configStringified: JSON.stringify(current[pathParts[pathParts.length - 1]]), - oldConfigStringified: JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null), - isConfigChanged: - JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null) !== - JSON.stringify(current[pathParts[pathParts.length - 1]]), - timestamp: new Date().toISOString(), - }); - } - - // gridColumns 변경 시 크기 자동 업데이트 - console.log("🔍 gridColumns 변경 감지:", { - path, - value, - componentType: newComp.type, - hasGridInfo: !!gridInfo, - hasGridSettings: !!layout.gridSettings, - currentGridColumns: (newComp as any).gridColumns, - }); - - if (path === "gridColumns" && gridInfo) { - const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings); - newComp.size = updatedSize; - console.log("📏 gridColumns 변경으로 크기 업데이트:", { - gridColumns: value, - oldSize: comp.size, - newSize: updatedSize, - }); - } else if (path === "gridColumns") { - console.log("❌ gridColumns 변경 실패:", { - hasGridInfo: !!gridInfo, - hasGridSettings: !!layout.gridSettings, - gridInfo, - gridSettings: layout.gridSettings, - }); - } - - // 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외) - if ( - (path === "size.width" || path === "size.height") && - layout.gridSettings?.snapToGrid && - gridInfo && - newComp.type !== "group" - ) { - // 현재 해상도에 맞는 격자 정보로 스냅 적용 - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, layout.gridSettings as GridUtilSettings); - newComp.size = snappedSize; - - // 크기 변경 시 gridColumns도 자동 조정 - const adjustedColumns = adjustGridColumnsFromSize( - newComp, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - if (newComp.gridColumns !== adjustedColumns) { - newComp.gridColumns = adjustedColumns; - console.log("📏 크기 변경으로 gridColumns 자동 조정:", { - oldColumns: comp.gridColumns, - newColumns: adjustedColumns, - newSize: snappedSize, - }); - } - } - - // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 - if (path === "gridColumns" && layout.gridSettings?.snapToGrid && newComp.type !== "group") { - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - // gridColumns에 맞는 정확한 너비 계산 - const newWidth = calculateWidthFromColumns( - newComp.gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - newComp.size = { - ...newComp.size, - width: newWidth, - }; - - console.log("📐 gridColumns 변경으로 크기 자동 조정:", { - componentId, - gridColumns: newComp.gridColumns, - oldWidth: comp.size.width, - newWidth: newWidth, - columnWidth: currentGridInfo.columnWidth, - gap: layout.gridSettings.gap, - }); - } - - // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) - if ( - (path === "position.x" || path === "position.y" || path === "position") && - layout.gridSettings?.snapToGrid - ) { - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 - if (newComp.parentId && currentGridInfo) { - const { columnWidth } = currentGridInfo; - const { gap } = layout.gridSettings; - - // 그룹 내부 패딩 고려한 격자 정렬 - const padding = 16; - const effectiveX = newComp.position.x - padding; - const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); - const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - - // Y 좌표는 20px 단위로 스냅 - const effectiveY = newComp.position.y - padding; - const rowIndex = Math.round(effectiveY / 20); - const snappedY = padding + rowIndex * 20; - - // 크기도 외부 격자와 동일하게 스냅 - const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 - const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); - const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20); - - newComp.position = { - x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 - y: Math.max(padding, snappedY), - z: newComp.position.z || 1, - }; - - newComp.size = { - width: snappedWidth, - height: snappedHeight, - }; - - console.log("🎯 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", { - componentId, - parentId: newComp.parentId, - originalPosition: comp.position, - originalSize: comp.size, - calculation: { - effectiveX, - effectiveY, - columnIndex, - rowIndex, - columnWidth, - fullColumnWidth, - widthInColumns, - gap: gap || 16, - padding, - }, - snappedPosition: newComp.position, - snappedSize: newComp.size, - }); - } else if (newComp.type !== "group") { - // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 - const snappedPosition = snapToGrid( - newComp.position, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - newComp.position = snappedPosition; - - console.log("🧲 일반 컴포넌트 격자 스냅:", { - componentId, - originalPosition: comp.position, - snappedPosition, - }); - } else { - console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", { - componentId, - type: newComp.type, - position: newComp.position, - }); - } - } - - return newComp; - }); - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 - if (selectedComponent && selectedComponent.id === componentId) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); - if (updatedSelectedComponent) { - console.log("🔄 selectedComponent 동기화:", { + console.log("✅ 컴포넌트 업데이트 완료:", { componentId, path, - oldColumnsCount: - selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A", - newColumnsCount: - updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A", - oldFiltersCount: - selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A", - newFiltersCount: - updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A", + newValue: current[pathParts[pathParts.length - 1]], + fullComponent: newComp, + webTypeConfig: newComp.type === "widget" ? (newComp as any).webTypeConfig : null, + }); + + // webTypeConfig 업데이트의 경우 추가 검증 + if (path === "webTypeConfig") { + console.log("🎛️ webTypeConfig 특별 처리:", { + componentId, + oldConfig: comp.type === "widget" ? (comp as any).webTypeConfig : null, + newConfig: current[pathParts[pathParts.length - 1]], + configType: typeof current[pathParts[pathParts.length - 1]], + configStringified: JSON.stringify(current[pathParts[pathParts.length - 1]]), + oldConfigStringified: JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null), + isConfigChanged: + JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null) !== + JSON.stringify(current[pathParts[pathParts.length - 1]]), + timestamp: new Date().toISOString(), + }); + } + + // gridColumns 변경 시 크기 자동 업데이트 + console.log("🔍 gridColumns 변경 감지:", { + path, + value, + componentType: newComp.type, + hasGridInfo: !!gridInfo, + hasGridSettings: !!layout.gridSettings, + currentGridColumns: (newComp as any).gridColumns, + }); + + if (path === "gridColumns" && gridInfo) { + const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings); + newComp.size = updatedSize; + console.log("📏 gridColumns 변경으로 크기 업데이트:", { + gridColumns: value, + oldSize: comp.size, + newSize: updatedSize, + }); + } else if (path === "gridColumns") { + console.log("❌ gridColumns 변경 실패:", { + hasGridInfo: !!gridInfo, + hasGridSettings: !!layout.gridSettings, + gridInfo, + gridSettings: layout.gridSettings, + }); + } + + // 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외) + if ( + (path === "size.width" || path === "size.height") && + prevLayout.gridSettings?.snapToGrid && + gridInfo && + newComp.type !== "group" + ) { + // 현재 해상도에 맞는 격자 정보로 스냅 적용 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: prevLayout.gridSettings.columns, + gap: prevLayout.gridSettings.gap, + padding: prevLayout.gridSettings.padding, + snapToGrid: prevLayout.gridSettings.snapToGrid || false, + }); + const snappedSize = snapSizeToGrid( + newComp.size, + currentGridInfo, + prevLayout.gridSettings as GridUtilSettings, + ); + newComp.size = snappedSize; + + // 크기 변경 시 gridColumns도 자동 조정 + const adjustedColumns = adjustGridColumnsFromSize( + newComp, + currentGridInfo, + prevLayout.gridSettings as GridUtilSettings, + ); + if (newComp.gridColumns !== adjustedColumns) { + newComp.gridColumns = adjustedColumns; + console.log("📏 크기 변경으로 gridColumns 자동 조정:", { + oldColumns: comp.gridColumns, + newColumns: adjustedColumns, + newSize: snappedSize, + }); + } + } + + // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 + if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") { + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: prevLayout.gridSettings.columns, + gap: prevLayout.gridSettings.gap, + padding: prevLayout.gridSettings.padding, + snapToGrid: prevLayout.gridSettings.snapToGrid || false, + }); + + // gridColumns에 맞는 정확한 너비 계산 + const newWidth = calculateWidthFromColumns( + newComp.gridColumns, + currentGridInfo, + prevLayout.gridSettings as GridUtilSettings, + ); + newComp.size = { + ...newComp.size, + width: newWidth, + }; + + console.log("📐 gridColumns 변경으로 크기 자동 조정:", { + componentId, + gridColumns: newComp.gridColumns, + oldWidth: comp.size.width, + newWidth: newWidth, + columnWidth: currentGridInfo.columnWidth, + gap: layout.gridSettings.gap, + }); + } + + // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) + if ( + (path === "position.x" || path === "position.y" || path === "position") && + layout.gridSettings?.snapToGrid + ) { + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + + // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 + if (newComp.parentId && currentGridInfo) { + const { columnWidth } = currentGridInfo; + const { gap } = layout.gridSettings; + + // 그룹 내부 패딩 고려한 격자 정렬 + const padding = 16; + const effectiveX = newComp.position.x - padding; + const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); + const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); + + // Y 좌표는 20px 단위로 스냅 + const effectiveY = newComp.position.y - padding; + const rowIndex = Math.round(effectiveY / 20); + const snappedY = padding + rowIndex * 20; + + // 크기도 외부 격자와 동일하게 스냅 + const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 + const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); + const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 + const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20); + + newComp.position = { + x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 + y: Math.max(padding, snappedY), + z: newComp.position.z || 1, + }; + + newComp.size = { + width: snappedWidth, + height: snappedHeight, + }; + + console.log("🎯 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", { + componentId, + parentId: newComp.parentId, + originalPosition: comp.position, + originalSize: comp.size, + calculation: { + effectiveX, + effectiveY, + columnIndex, + rowIndex, + columnWidth, + fullColumnWidth, + widthInColumns, + gap: gap || 16, + padding, + }, + snappedPosition: newComp.position, + snappedSize: newComp.size, + }); + } else if (newComp.type !== "group") { + // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 + const snappedPosition = snapToGrid( + newComp.position, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + newComp.position = snappedPosition; + + console.log("🧲 일반 컴포넌트 격자 스냅:", { + componentId, + originalPosition: comp.position, + snappedPosition, + }); + } else { + console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", { + componentId, + type: newComp.type, + position: newComp.position, + }); + } + } + + return newComp; + }); + + // 🔥 새로운 layout 생성 + const newLayout = { ...prevLayout, components: updatedComponents }; + + console.log("🔄 setLayout 실행:", { + componentId, + path, + value, + 업데이트된컴포넌트: updatedComponents.find((c) => c.id === componentId), + }); + + saveToHistory(newLayout); + + // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 + setSelectedComponent((prevSelected) => { + if (prevSelected && prevSelected.id === componentId) { + const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); + if (updatedSelectedComponent) { + // 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함 + const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); + + console.log("🔄 selectedComponent 동기화:", { + componentId, + path, + oldAction: (prevSelected as any).componentConfig?.action, + newAction: (newSelectedComponent as any).componentConfig?.action, + oldColumnsCount: prevSelected.type === "datatable" ? (prevSelected as any).columns?.length : "N/A", + newColumnsCount: + newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).columns?.length : "N/A", + oldFiltersCount: prevSelected.type === "datatable" ? (prevSelected as any).filters?.length : "N/A", + newFiltersCount: + newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).filters?.length : "N/A", + timestamp: new Date().toISOString(), + }); + return newSelectedComponent; + } + } + return prevSelected; + }); + + // webTypeConfig 업데이트 후 레이아웃 상태 확인 + if (path === "webTypeConfig") { + const updatedComponent = newLayout.components.find((c) => c.id === componentId); + console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", { + componentId, + updatedComponent: updatedComponent + ? { + id: updatedComponent.id, + type: updatedComponent.type, + webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, + } + : null, + layoutComponentsCount: newLayout.components.length, timestamp: new Date().toISOString(), }); - setSelectedComponent(updatedSelectedComponent); } - } - // webTypeConfig 업데이트 후 레이아웃 상태 확인 - if (path === "webTypeConfig") { - const updatedComponent = newLayout.components.find((c) => c.id === componentId); - console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", { - componentId, - updatedComponent: updatedComponent - ? { - id: updatedComponent.id, - type: updatedComponent.type, - webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, - } - : null, - layoutComponentsCount: newLayout.components.length, - timestamp: new Date().toISOString(), - }); - } + return newLayout; + }); }, - [layout, gridInfo, saveToHistory], + [gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거! ); // 컴포넌트 시스템 초기화 @@ -1294,11 +1330,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD components: updatedComponents, screenResolution: screenResolution, }; + // 🔍 버튼 컴포넌트들의 action.type 확인 + const buttonComponents = layoutWithResolution.components.filter( + (c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary", + ); console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length, gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, + buttonComponents: buttonComponents.map((c: any) => ({ + id: c.id, + type: c.type, + text: c.componentConfig?.text, + actionType: c.componentConfig?.action?.type, + fullAction: c.componentConfig?.action, + })), }); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); @@ -2125,6 +2172,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 새 컴포넌트 선택 setSelectedComponent(newComponent); + // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 + // openPanel("properties"); toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, @@ -2606,6 +2655,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setLayout(newLayout); saveToHistory(newLayout); setSelectedComponent(newComponent); + + // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 + // openPanel("properties"); } catch (error) { // console.error("드롭 처리 실패:", error); } @@ -2668,47 +2720,72 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return; } + // 🔧 layout.components에서 최신 버전의 컴포넌트 찾기 + const latestComponent = layout.components.find((c) => c.id === component.id); + if (!latestComponent) { + console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id); + return; + } + + console.log("🔍 컴포넌트 클릭 시 최신 버전 확인:", { + componentId: component.id, + 파라미터로받은버전: { + actionType: (component as any).componentConfig?.action?.type, + fullAction: (component as any).componentConfig?.action, + }, + layout에서찾은최신버전: { + actionType: (latestComponent as any).componentConfig?.action?.type, + fullAction: (latestComponent as any).componentConfig?.action, + }, + }); + const isShiftPressed = event?.shiftKey || false; const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; - const isGroupContainer = component.type === "group"; + const isGroupContainer = latestComponent.type === "group"; if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { // 다중 선택 모드 if (isGroupContainer) { // 그룹 컨테이너는 단일 선택으로 처리 - handleComponentSelect(component); + handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 setGroupState((prev) => ({ ...prev, - selectedComponents: [component.id], + selectedComponents: [latestComponent.id], isGrouping: false, })); return; } - const isSelected = groupState.selectedComponents.includes(component.id); + const isSelected = groupState.selectedComponents.includes(latestComponent.id); setGroupState((prev) => ({ ...prev, selectedComponents: isSelected - ? prev.selectedComponents.filter((id) => id !== component.id) - : [...prev.selectedComponents, component.id], + ? prev.selectedComponents.filter((id) => id !== latestComponent.id) + : [...prev.selectedComponents, latestComponent.id], })); // 마지막 선택된 컴포넌트를 selectedComponent로 설정 if (!isSelected) { - // console.log("🎯 컴포넌트 선택 (다중 모드):", component.id); - handleComponentSelect(component); + // console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id); + handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 } } else { // 단일 선택 모드 - // console.log("🎯 컴포넌트 선택 (단일 모드):", component.id); - handleComponentSelect(component); + // console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id); + handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 setGroupState((prev) => ({ ...prev, - selectedComponents: [component.id], + selectedComponents: [latestComponent.id], })); } }, - [handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag], + [ + handleComponentSelect, + groupState.isGrouping, + groupState.selectedComponents, + dragState.justFinishedDrag, + layout.components, + ], ); // 컴포넌트 드래그 시작 @@ -3937,305 +4014,314 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6" > {/* Pan 모드 안내 - 제거됨 */} - {/* 줌 레벨 표시 */}
🔍 {Math.round(zoomLevel * 100)}%
- - {/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */} + {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
+ {/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */}
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - // Pan 모드가 아닐 때만 다중 선택 시작 - if (e.target === e.currentTarget && !isPanMode) { - startSelectionDrag(e); - } - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onDrop={(e) => { - e.preventDefault(); - // console.log("🎯 캔버스 드롭 이벤트 발생"); - handleDrop(e); + className="bg-white shadow-lg" + style={{ + width: screenResolution.width, + height: Math.max(screenResolution.height, 800), // 최소 높이 보장 + minHeight: screenResolution.height, + transform: `scale(${zoomLevel})`, // 줌 레벨에 따라 시각적으로 확대/축소 + transformOrigin: "top center", + transition: "transform 0.1s ease-out", }} > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} +
{ + if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }} + onMouseDown={(e) => { + // Pan 모드가 아닐 때만 다중 선택 시작 + if (e.target === e.currentTarget && !isPanMode) { + startSelectionDrag(e); + } + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={(e) => { + e.preventDefault(); + // console.log("🎯 캔버스 드롭 이벤트 발생"); + handleDrop(e); + }} + > + {/* 격자 라인 */} + {gridLines.map((line, index) => ( +
+ ))} - {/* 컴포넌트들 */} - {layout.components - .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; + {/* 컴포넌트들 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .map((component) => { + const children = + component.type === "group" + ? layout.components.filter((child) => child.parentId === component.id) + : []; - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); + // 드래그 중 시각적 피드백 (다중 선택 지원) + const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; + const isBeingDragged = + dragState.isDragging && + dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); - let displayComponent = component; - - if (isBeingDragged) { - if (isDraggingThis) { - // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 - displayComponent = { - ...component, - position: dragState.currentPosition, - style: { - ...component.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 - const originalComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === component.id, - ); - if (originalComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + let displayComponent = component; + if (isBeingDragged) { + if (isDraggingThis) { + // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 displayComponent = { ...component, - position: { - x: originalComponent.position.x + deltaX, - y: originalComponent.position.y + deltaY, - z: originalComponent.position.z || 1, - } as Position, + position: dragState.currentPosition, style: { ...component.style, opacity: 0.8, + transform: "scale(1.02)", transition: "none", - zIndex: 40, // 주 컴포넌트보다 약간 낮게 + zIndex: 50, }, }; + } else { + // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 + const originalComponent = dragState.draggedComponents.find( + (dragComp) => dragComp.id === component.id, + ); + if (originalComponent) { + const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; + const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + + displayComponent = { + ...component, + position: { + x: originalComponent.position.x + deltaX, + y: originalComponent.position.y + deltaY, + z: originalComponent.position.z || 1, + } as Position, + style: { + ...component.style, + opacity: 0.8, + transition: "none", + zIndex: 40, // 주 컴포넌트보다 약간 낮게 + }, + }; + } } } - } - // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 - const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; - const componentFiles = (component as any).uploadedFiles || []; - const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; + // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 + const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; + const globalFiles = globalFileState[component.id] || []; + const componentFiles = (component as any).uploadedFiles || []; + const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); + return ( + handleComponentClick(component, e)} + onDoubleClick={(e) => handleComponentDoubleClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + selectedScreen={selectedScreen} + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) + onConfigChange={(config) => { + // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, - }, - }; - } - return comp; - }); + // 컴포넌트의 componentConfig 업데이트 + const updatedComponents = layout.components.map((comp) => { + if (comp.id === component.id) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + ...config, + }, + }; + } + return comp; + }); - const newLayout = { - ...layout, - components: updatedComponents, - }; + const newLayout = { + ...layout, + components: updatedComponents, + }; - setLayout(newLayout); - saveToHistory(newLayout); + setLayout(newLayout); + saveToHistory(newLayout); - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - > - {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트에도 드래그 피드백 적용 - const isChildDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === child.id; - const isChildBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); + console.log("✅ 컴포넌트 설정 업데이트 완료:", { + componentId: component.id, + updatedConfig: config, + }); + }} + > + {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} + {(component.type === "group" || component.type === "container" || component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트에도 드래그 피드백 적용 + const isChildDraggingThis = + dragState.isDragging && dragState.draggedComponent?.id === child.id; + const isChildBeingDragged = + dragState.isDragging && + dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); - let displayChild = child; - - if (isChildBeingDragged) { - if (isChildDraggingThis) { - // 주 드래그 자식 컴포넌트 - displayChild = { - ...child, - position: dragState.currentPosition, - style: { - ...child.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 자식 컴포넌트들 - const originalChildComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === child.id, - ); - if (originalChildComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + let displayChild = child; + if (isChildBeingDragged) { + if (isChildDraggingThis) { + // 주 드래그 자식 컴포넌트 displayChild = { ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, + position: dragState.currentPosition, style: { ...child.style, opacity: 0.8, + transform: "scale(1.02)", transition: "none", - zIndex: 8888, + zIndex: 50, }, }; + } else { + // 다른 선택된 자식 컴포넌트들 + const originalChildComponent = dragState.draggedComponents.find( + (dragComp) => dragComp.id === child.id, + ); + if (originalChildComponent) { + const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; + const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + + displayChild = { + ...child, + position: { + x: originalChildComponent.position.x + deltaX, + y: originalChildComponent.position.y + deltaY, + z: originalChildComponent.position.z || 1, + } as Position, + style: { + ...child.style, + opacity: 0.8, + transition: "none", + zIndex: 8888, + }, + }; + } } } - } - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...displayChild, + position: { + x: displayChild.position.x - component.position.x, + y: displayChild.position.y - component.position.y, + z: displayChild.position.z || 1, + }, + }; - return ( - f.objid) || [])}`} - component={relativeChildComponent} - isSelected={ - selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id) - } - isDesignMode={true} // 편집 모드로 설정 - onClick={(e) => handleComponentClick(child, e)} - onDoubleClick={(e) => handleComponentDoubleClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - /> - ); - })} - - ); - })} + return ( + f.objid) || [])}`} + component={relativeChildComponent} + isSelected={ + selectedComponent?.id === child.id || + groupState.selectedComponents.includes(child.id) + } + isDesignMode={true} // 편집 모드로 설정 + onClick={(e) => handleComponentClick(child, e)} + onDoubleClick={(e) => handleComponentDoubleClick(child, e)} + onDragStart={(e) => startComponentDrag(child, e)} + onDragEnd={endDrag} + selectedScreen={selectedScreen} + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (자식 컴포넌트용) + onConfigChange={(config) => { + // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); + // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 + }} + /> + ); + })} + + ); + })} - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} + {/* 드래그 선택 영역 */} + {selectionDrag.isSelecting && ( +
+ )} - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
- -

캔버스가 비어있습니다

-

좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

-

- 단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도) -

-

- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제) -

-

- ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 -

+ {/* 빈 캔버스 안내 */} + {layout.components.length === 0 && ( +
+
+ +

캔버스가 비어있습니다

+

좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

+

+ 단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도) +

+

+ 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제) +

+

+ ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 +

+
-
- )} + )} +
-
+
{" "} + {/* 🔥 줌 래퍼 닫기 */}
{" "} {/* 메인 컨테이너 닫기 */} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx new file mode 100644 index 00000000..a70a0633 --- /dev/null +++ b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx @@ -0,0 +1,625 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ComponentData } from "@/types/screen"; +import { apiClient } from "@/lib/api/client"; +import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; +import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; + +interface ButtonConfigPanelProps { + component: ComponentData; + onUpdateProperty: (path: string, value: any) => void; +} + +interface ScreenOption { + id: number; + name: string; + description?: string; +} + +export const ButtonConfigPanel: React.FC = ({ component, onUpdateProperty }) => { + // 🔧 항상 최신 component에서 직접 참조 + const config = component.componentConfig || {}; + const currentAction = component.componentConfig?.action || {}; // 🔧 최신 action 참조 + + // 로컬 상태 관리 (실시간 입력 반영) + const [localInputs, setLocalInputs] = useState({ + text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용 + modalTitle: config.action?.modalTitle || "", + editModalTitle: config.action?.editModalTitle || "", + editModalDescription: config.action?.editModalDescription || "", + targetUrl: config.action?.targetUrl || "", + }); + + const [localSelects, setLocalSelects] = useState({ + variant: config.variant || "default", + size: config.size || "md", // 🔧 기본값을 "md"로 변경 + actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined) + modalSize: config.action?.modalSize || "md", + editMode: config.action?.editMode || "modal", + }); + + const [screens, setScreens] = useState([]); + const [screensLoading, setScreensLoading] = useState(false); + const [modalScreenOpen, setModalScreenOpen] = useState(false); + const [navScreenOpen, setNavScreenOpen] = useState(false); + const [modalSearchTerm, setModalSearchTerm] = useState(""); + const [navSearchTerm, setNavSearchTerm] = useState(""); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + console.log("🔄 ButtonConfigPanel useEffect 실행:", { + componentId: component.id, + "config.action?.type": config.action?.type, + "localSelects.actionType (before)": localSelects.actionType, + fullAction: config.action, + "component.componentConfig.action": component.componentConfig?.action, + }); + + setLocalInputs({ + text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용 + modalTitle: config.action?.modalTitle || "", + editModalTitle: config.action?.editModalTitle || "", + editModalDescription: config.action?.editModalDescription || "", + targetUrl: config.action?.targetUrl || "", + }); + + setLocalSelects((prev) => { + const newSelects = { + variant: config.variant || "default", + size: config.size || "md", // 🔧 기본값을 "md"로 변경 + actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined) + modalSize: config.action?.modalSize || "md", + editMode: config.action?.editMode || "modal", + }; + + console.log("📝 setLocalSelects 호출:", { + "prev.actionType": prev.actionType, + "new.actionType": newSelects.actionType, + "config.action?.type": config.action?.type, + }); + + return newSelects; + }); + }, [ + component.id, // 🔧 컴포넌트 ID (다른 컴포넌트로 전환 시) + component.componentConfig?.action?.type, // 🔧 액션 타입 (액션 변경 시 즉시 반영) + component.componentConfig?.text, // 🔧 버튼 텍스트 + component.componentConfig?.variant, // 🔧 버튼 스타일 + component.componentConfig?.size, // 🔧 버튼 크기 + ]); + + // 화면 목록 가져오기 + useEffect(() => { + const fetchScreens = async () => { + try { + setScreensLoading(true); + const response = await apiClient.get("/screen-management/screens"); + + if (response.data.success && Array.isArray(response.data.data)) { + const screenList = response.data.data.map((screen: any) => ({ + id: screen.screenId, + name: screen.screenName, + description: screen.description, + })); + setScreens(screenList); + } + } catch (error) { + // console.error("❌ 화면 목록 로딩 실패:", error); + } finally { + setScreensLoading(false); + } + }; + + fetchScreens(); + }, []); + + // 검색 필터링 함수 + const filterScreens = (searchTerm: string) => { + if (!searchTerm.trim()) return screens; + return screens.filter( + (screen) => + screen.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())), + ); + }; + + console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", { + component, + config, + action: config.action, + actionType: config.action?.type, + screensCount: screens.length, + }); + + return ( +
+
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, text: newValue })); + onUpdateProperty("componentConfig.text", newValue); + }} + placeholder="버튼 텍스트를 입력하세요" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {/* 모달 열기 액션 설정 */} + {localSelects.actionType === "modal" && ( +
+

모달 설정

+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); + onUpdateProperty("componentConfig.action.modalTitle", newValue); + }} + /> +
+ +
+ + +
+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+
+
+ )} + + {/* 수정 액션 설정 */} + {localSelects.actionType === "edit" && ( +
+

수정 설정

+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ 선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다 +

+
+ +
+ + +
+ + {localSelects.editMode === "modal" && ( + <> +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); + onUpdateProperty("componentConfig.action.editModalTitle", newValue); + onUpdateProperty("webTypeConfig.editModalTitle", newValue); + }} + /> +

비워두면 기본 제목이 표시됩니다

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); + onUpdateProperty("componentConfig.action.editModalDescription", newValue); + onUpdateProperty("webTypeConfig.editModalDescription", newValue); + }} + /> +

비워두면 설명이 표시되지 않습니다

+
+ +
+ + +
+ + )} +
+ )} + + {/* 페이지 이동 액션 설정 */} + {localSelects.actionType === "navigate" && ( +
+

페이지 이동 설정

+ +
+ + + + + + +
+
+ + setNavSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(navSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setNavScreenOpen(false); + setNavSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ 선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다 +

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, targetUrl: newValue })); + onUpdateProperty("componentConfig.action.targetUrl", newValue); + }} + /> +

URL을 입력하면 화면 선택보다 우선 적용됩니다

+
+
+ )} + + {/* 🔥 NEW: 제어관리 기능 섹션 */} +
+
+

🔧 고급 기능

+

버튼 액션과 함께 실행될 추가 기능을 설정합니다

+
+ + +
+
+ ); +}; + diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 2cb1e3ce..dac32163 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -26,25 +26,24 @@ interface ScreenOption { } export const ButtonConfigPanel: React.FC = ({ component, onUpdateProperty }) => { + console.log("🎨 ButtonConfigPanel 렌더링:", { + componentId: component.id, + "component.componentConfig?.action?.type": component.componentConfig?.action?.type, + }); + + // 🔧 component에서 직접 읽기 (useMemo 제거) const config = component.componentConfig || {}; + const currentAction = component.componentConfig?.action || {}; // 로컬 상태 관리 (실시간 입력 반영) const [localInputs, setLocalInputs] = useState({ - text: config.text || "버튼", + text: config.text !== undefined ? config.text : "버튼", modalTitle: config.action?.modalTitle || "", editModalTitle: config.action?.editModalTitle || "", editModalDescription: config.action?.editModalDescription || "", targetUrl: config.action?.targetUrl || "", }); - const [localSelects, setLocalSelects] = useState({ - variant: config.variant || "default", - size: config.size || "default", - actionType: config.action?.type || "save", - modalSize: config.action?.modalSize || "md", - editMode: config.action?.editMode || "modal", - }); - const [screens, setScreens] = useState([]); const [screensLoading, setScreensLoading] = useState(false); const [modalScreenOpen, setModalScreenOpen] = useState(false); @@ -52,44 +51,27 @@ export const ButtonConfigPanel: React.FC = ({ component, const [modalSearchTerm, setModalSearchTerm] = useState(""); const [navSearchTerm, setNavSearchTerm] = useState(""); - // 컴포넌트 변경 시 로컬 상태 동기화 + // 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만) useEffect(() => { - setLocalInputs({ - text: config.text || "버튼", - modalTitle: config.action?.modalTitle || "", - editModalTitle: config.action?.editModalTitle || "", - editModalDescription: config.action?.editModalDescription || "", - targetUrl: config.action?.targetUrl || "", - }); + const latestConfig = component.componentConfig || {}; + const latestAction = latestConfig.action || {}; - setLocalSelects({ - variant: config.variant || "default", - size: config.size || "default", - actionType: config.action?.type || "save", - modalSize: config.action?.modalSize || "md", - editMode: config.action?.editMode || "modal", + setLocalInputs({ + text: latestConfig.text !== undefined ? latestConfig.text : "버튼", + modalTitle: latestAction.modalTitle || "", + editModalTitle: latestAction.editModalTitle || "", + editModalDescription: latestAction.editModalDescription || "", + targetUrl: latestAction.targetUrl || "", }); - }, [ - config.text, - config.variant, - config.size, - config.action?.type, - config.action?.modalTitle, - config.action?.modalSize, - config.action?.editMode, - config.action?.editModalTitle, - config.action?.editModalDescription, - config.action?.targetUrl, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [component.id]); // 화면 목록 가져오기 useEffect(() => { const fetchScreens = async () => { try { setScreensLoading(true); - // console.log("🔍 화면 목록 API 호출 시작"); const response = await apiClient.get("/screen-management/screens"); - // console.log("✅ 화면 목록 API 응답:", response.data); if (response.data.success && Array.isArray(response.data.data)) { const screenList = response.data.data.map((screen: any) => ({ @@ -98,7 +80,6 @@ export const ButtonConfigPanel: React.FC = ({ component, description: screen.description, })); setScreens(screenList); - // console.log("✅ 화면 목록 설정 완료:", screenList.length, "개"); } } catch (error) { // console.error("❌ 화면 목록 로딩 실패:", error); @@ -120,13 +101,13 @@ export const ButtonConfigPanel: React.FC = ({ component, ); }; - console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", { - component, - config, - action: config.action, - actionType: config.action?.type, - screensCount: screens.length, - }); + // console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", { + // component, + // config, + // action: config.action, + // actionType: config.action?.type, + // screensCount: screens.length, + // }); return (
@@ -147,9 +128,8 @@ export const ButtonConfigPanel: React.FC = ({ component,
{ - setLocalSelects((prev) => ({ ...prev, size: value })); onUpdateProperty("componentConfig.size", value); }} > - + - 작음 (Small) - 기본 (Default) - 큼 (Large) + 작음 (Small) + 기본 (Default) + 큼 (Large)
@@ -191,28 +170,23 @@ export const ButtonConfigPanel: React.FC = ({ component,
{ - setLocalSelects((prev) => ({ ...prev, modalSize: value })); - onUpdateProperty("componentConfig.action", { - ...config.action, - modalSize: value, - }); + onUpdateProperty("componentConfig.action.modalSize", value); }} > @@ -301,7 +268,6 @@ export const ButtonConfigPanel: React.FC = ({ component,
- {/* 검색 입력 */}
= ({ component, className="border-0 p-0 focus-visible:ring-0" />
- {/* 검색 결과 */}
{(() => { const filteredScreens = filterScreens(modalSearchTerm); @@ -326,10 +291,7 @@ export const ButtonConfigPanel: React.FC = ({ component, key={`modal-screen-${screen.id}-${index}`} className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100" onClick={() => { - onUpdateProperty("componentConfig.action", { - ...config.action, - targetScreenId: screen.id, - }); + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} @@ -356,7 +318,7 @@ export const ButtonConfigPanel: React.FC = ({ component, )} {/* 수정 액션 설정 */} - {localSelects.actionType === "edit" && ( + {(component.componentConfig?.action?.type || "save") === "edit" && (

수정 설정

@@ -380,7 +342,6 @@ export const ButtonConfigPanel: React.FC = ({ component,
- {/* 검색 입력 */}
= ({ component, className="border-0 p-0 focus-visible:ring-0" />
- {/* 검색 결과 */}
{(() => { const filteredScreens = filterScreens(modalSearchTerm); @@ -405,10 +365,7 @@ export const ButtonConfigPanel: React.FC = ({ component, key={`edit-screen-${screen.id}-${index}`} className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100" onClick={() => { - onUpdateProperty("componentConfig.action", { - ...config.action, - targetScreenId: screen.id, - }); + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} @@ -438,13 +395,9 @@ export const ButtonConfigPanel: React.FC = ({ component,
- {localSelects.editMode === "modal" && ( + {(component.componentConfig?.action?.editMode || "modal") === "modal" && ( <>
@@ -469,11 +422,7 @@ export const ButtonConfigPanel: React.FC = ({ component, onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); - onUpdateProperty("componentConfig.action", { - ...config.action, - editModalTitle: newValue, - }); - // webTypeConfig에도 저장 + onUpdateProperty("componentConfig.action.editModalTitle", newValue); onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} /> @@ -489,11 +438,7 @@ export const ButtonConfigPanel: React.FC = ({ component, onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); - onUpdateProperty("componentConfig.action", { - ...config.action, - editModalDescription: newValue, - }); - // webTypeConfig에도 저장 + onUpdateProperty("componentConfig.action.editModalDescription", newValue); onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} /> @@ -503,13 +448,9 @@ export const ButtonConfigPanel: React.FC = ({ component,
= ({ component, className="border-0 p-0 focus-visible:ring-0" />
- {/* 검색 결과 */}
{(() => { const filteredScreens = filterScreens(navSearchTerm); @@ -579,10 +518,7 @@ export const ButtonConfigPanel: React.FC = ({ component, key={`navigate-screen-${screen.id}-${index}`} className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100" onClick={() => { - onUpdateProperty("componentConfig.action", { - ...config.action, - targetScreenId: screen.id, - }); + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setNavScreenOpen(false); setNavSearchTerm(""); }} @@ -618,10 +554,7 @@ export const ButtonConfigPanel: React.FC = ({ component, onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, targetUrl: newValue })); - onUpdateProperty("componentConfig.action", { - ...config.action, - targetUrl: newValue, - }); + onUpdateProperty("componentConfig.action.targetUrl", newValue); }} />

URL을 입력하면 화면 선택보다 우선 적용됩니다

@@ -641,3 +574,4 @@ export const ButtonConfigPanel: React.FC = ({ component,
); }; + diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 84fdf251..9540c21b 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -822,7 +822,8 @@ export const DetailSettingsPanel: React.FC = ({ case "button": case "button-primary": case "button-secondary": - return ; + // 🔧 component.id만 key로 사용 (unmount 방지) + return ; case "card": return ; diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 6c325ac8..71172d4d 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -123,7 +123,8 @@ export const UnifiedPropertiesPanel: React.FC = ({ case "button": case "button-primary": case "button-secondary": - return ; + // 🔧 component.id만 key로 사용 (unmount 방지) + return ; case "card": return ; diff --git a/frontend/lib/api/externalDbConnection.ts b/frontend/lib/api/externalDbConnection.ts index aa161af7..257a7a3f 100644 --- a/frontend/lib/api/externalDbConnection.ts +++ b/frontend/lib/api/externalDbConnection.ts @@ -27,6 +27,36 @@ export interface ExternalDbConnection { updated_by?: string; } +export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; + +export interface ExternalApiConnection { + id?: number; + connection_name: string; + description?: string; + base_url: string; + default_headers: Record; + auth_type: AuthType; + auth_config?: { + keyLocation?: "header" | "query"; + keyName?: string; + keyValue?: string; + token?: string; + username?: string; + password?: string; + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + accessToken?: string; + }; + timeout?: number; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; +} + export interface ExternalDbConnectionFilter { db_type?: string; is_active?: string; @@ -209,7 +239,7 @@ export class ExternalDbConnectionAPI { try { const response = await apiClient.post>( `${this.BASE_PATH}/${connectionId}/test`, - password ? { password } : undefined + password ? { password } : undefined, ); if (!response.data.success) { @@ -220,10 +250,12 @@ export class ExternalDbConnectionAPI { }; } - return response.data.data || { - success: true, - message: response.data.message || "연결 테스트가 완료되었습니다.", - }; + return ( + response.data.data || { + success: true, + message: response.data.message || "연결 테스트가 완료되었습니다.", + } + ); } catch (error) { console.error("연결 테스트 오류:", error); @@ -246,9 +278,7 @@ export class ExternalDbConnectionAPI { */ static async getTables(connectionId: number): Promise> { try { - const response = await apiClient.get>( - `${this.BASE_PATH}/${connectionId}/tables` - ); + const response = await apiClient.get>(`${this.BASE_PATH}/${connectionId}/tables`); return response.data; } catch (error) { console.error("테이블 목록 조회 오류:", error); @@ -260,7 +290,7 @@ export class ExternalDbConnectionAPI { try { console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`); const response = await apiClient.get>( - `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns` + `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`, ); console.log("컬럼 정보 API 응답:", response.data); return response.data; @@ -273,10 +303,7 @@ export class ExternalDbConnectionAPI { static async executeQuery(connectionId: number, query: string): Promise> { try { console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query }); - const response = await apiClient.post>( - `${this.BASE_PATH}/${connectionId}/execute`, - { query } - ); + const response = await apiClient.post>(`${this.BASE_PATH}/${connectionId}/execute`, { query }); console.log("API 응답:", response.data); return response.data; } catch (error) { @@ -284,4 +311,45 @@ export class ExternalDbConnectionAPI { throw error; } } + + /** + * REST API 연결 목록 조회 (외부 커넥션에서) + */ + static async getApiConnections(filter: { is_active?: string } = {}): Promise { + try { + const params = new URLSearchParams(); + if (filter.is_active) params.append("is_active", filter.is_active); + + const response = await apiClient.get>( + `/external-rest-api-connections?${params.toString()}`, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "API 연결 목록 조회에 실패했습니다."); + } + + return response.data.data || []; + } catch (error) { + console.error("API 연결 목록 조회 오류:", error); + return []; + } + } + + /** + * 특정 REST API 연결 조회 + */ + static async getApiConnectionById(id: number): Promise { + try { + const response = await apiClient.get>(`/external-rest-api-connections/${id}`); + + if (!response.data.success || !response.data.data) { + return null; + } + + return response.data.data; + } catch (error) { + console.error("API 연결 조회 오류:", error); + return null; + } + } } 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/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 80d3a001..0b0255dc 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -491,7 +491,8 @@ export const ButtonPrimaryComponent: React.FC = ({ ? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)" : `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`, color: componentConfig.disabled ? "#9ca3af" : "white", - fontSize: "0.875rem", + // 🔧 크기 설정 적용 (sm/md/lg) + fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", fontWeight: "600", cursor: componentConfig.disabled ? "not-allowed" : "pointer", outline: "none", @@ -499,10 +500,10 @@ export const ButtonPrimaryComponent: React.FC = ({ display: "flex", alignItems: "center", justifyContent: "center", - padding: "0 1rem", + // 🔧 크기에 따른 패딩 조정 + padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", margin: "0", lineHeight: "1.25", - minHeight: "2.25rem", boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`, // isInteractive 모드에서는 사용자 스타일 우선 적용 ...(isInteractive && component.style ? component.style : {}), @@ -511,7 +512,8 @@ export const ButtonPrimaryComponent: React.FC = ({ onDragStart={onDragStart} onDragEnd={onDragEnd} > - {processedConfig.text || component.label || "버튼"} + {/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */} + {processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
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. 로그 데이터 파티셔닝