diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index acbfbfcc..73e22583 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -108,6 +108,41 @@ export const deleteScreen = async ( } }; +// 화면 복사 +export const copyScreen = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { screenName, screenCode, description } = req.body; + const { companyCode, userId } = req.user as any; + + const copiedScreen = await screenManagementService.copyScreen( + parseInt(id), + { + screenName, + screenCode, + description, + companyCode, + createdBy: userId, + } + ); + + res.json({ + success: true, + data: copiedScreen, + message: "화면이 복사되었습니다.", + }); + } catch (error: any) { + console.error("화면 복사 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "화면 복사에 실패했습니다.", + }); + } +}; + // 테이블 목록 조회 (모든 테이블) export const getTables = async (req: AuthenticatedRequest, res: Response) => { try { diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 520c8f42..bbee31d4 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -439,3 +439,307 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +/** + * 테이블 데이터 조회 (페이징 + 검색) + */ +export async function getTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { + page = 1, + size = 10, + search = {}, + sortBy, + sortOrder = "asc", + } = req.body; + + logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); + logger.info(`페이징: page=${page}, size=${size}`); + logger.info(`검색 조건:`, search); + logger.info(`정렬: ${sortBy} ${sortOrder}`); + + 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.getTableData(tableName, { + page: parseInt(page), + size: parseInt(size), + search, + sortBy, + sortOrder, + }); + + logger.info( + `테이블 데이터 조회 완료: ${tableName}, 총 ${result.total}건, 페이지 ${result.page}/${result.totalPages}` + ); + + 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: "TABLE_DATA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 데이터 추가 + */ +export async function addTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const data = req.body; + + logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); + logger.info(`추가할 데이터:`, data); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!data || Object.keys(data).length === 0) { + const response: ApiResponse = { + success: false, + message: "추가할 데이터가 필요합니다.", + error: { + code: "MISSING_DATA", + details: "요청 본문에 데이터가 없습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 데이터 추가 + await tableManagementService.addTableData(tableName, data); + + logger.info(`테이블 데이터 추가 완료: ${tableName}`); + + const response: ApiResponse = { + success: true, + message: "테이블 데이터를 성공적으로 추가했습니다.", + }; + + res.status(201).json(response); + } catch (error) { + logger.error("테이블 데이터 추가 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 데이터 추가 중 오류가 발생했습니다.", + error: { + code: "TABLE_ADD_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 데이터 수정 + */ +export async function editTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { originalData, updatedData } = req.body; + + logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`); + logger.info(`원본 데이터:`, originalData); + logger.info(`수정할 데이터:`, updatedData); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "INVALID_TABLE_NAME", + details: "테이블명이 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!originalData || !updatedData) { + const response: ApiResponse = { + success: false, + message: "원본 데이터와 수정할 데이터가 모두 필요합니다.", + error: { + code: "INVALID_DATA", + details: "originalData와 updatedData가 모두 제공되어야 합니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (Object.keys(updatedData).length === 0) { + const response: ApiResponse = { + success: false, + message: "수정할 데이터가 없습니다.", + error: { + code: "INVALID_DATA", + details: "수정할 데이터가 비어있습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 데이터 수정 + await tableManagementService.editTableData( + tableName, + originalData, + updatedData + ); + + logger.info(`테이블 데이터 수정 완료: ${tableName}`); + + const response: ApiResponse = { + success: true, + message: "테이블 데이터를 성공적으로 수정했습니다.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 데이터 수정 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 데이터 수정 중 오류가 발생했습니다.", + error: { + code: "TABLE_EDIT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 데이터 삭제 + */ +export async function deleteTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const data = req.body; + + logger.info(`=== 테이블 데이터 삭제 시작: ${tableName} ===`); + logger.info(`삭제할 데이터:`, data); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!data || (Array.isArray(data) && data.length === 0)) { + const response: ApiResponse = { + success: false, + message: "삭제할 데이터가 필요합니다.", + error: { + code: "MISSING_DATA", + details: "요청 본문에 삭제할 데이터가 없습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 데이터 삭제 + const deletedCount = await tableManagementService.deleteTableData( + tableName, + data + ); + + logger.info( + `테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제` + ); + + const response: ApiResponse<{ deletedCount: number }> = { + success: true, + message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`, + data: { deletedCount }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 데이터 삭제 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 데이터 삭제 중 오류가 발생했습니다.", + error: { + code: "TABLE_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 7e2dfd72..33fb8697 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -6,6 +6,7 @@ import { createScreen, updateScreen, deleteScreen, + copyScreen, getTables, getTableInfo, getTableColumns, @@ -28,6 +29,7 @@ router.get("/screens/:id", getScreen); router.post("/screens", createScreen); router.put("/screens/:id", updateScreen); router.delete("/screens/:id", deleteScreen); +router.post("/screens/:id/copy", copyScreen); // 화면 코드 자동 생성 router.get("/generate-screen-code/:companyCode", generateScreenCode); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 9851eb43..94558881 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -8,6 +8,10 @@ import { getTableLabels, getColumnLabels, updateColumnWebType, + getTableData, + addTableData, + editTableData, + deleteTableData, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -63,4 +67,28 @@ router.put( updateColumnWebType ); +/** + * 테이블 데이터 조회 (페이징 + 검색) + * POST /api/table-management/tables/:tableName/data + */ +router.post("/tables/:tableName/data", getTableData); + +/** + * 테이블 데이터 추가 + * POST /api/table-management/tables/:tableName/add + */ +router.post("/tables/:tableName/add", addTableData); + +/** + * 테이블 데이터 수정 + * PUT /api/table-management/tables/:tableName/edit + */ +router.put("/tables/:tableName/edit", editTableData); + +/** + * 테이블 데이터 삭제 + * DELETE /api/table-management/tables/:tableName/delete + */ +router.delete("/tables/:tableName/delete", deleteTableData); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 627bafff..c9049da5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -14,8 +14,18 @@ import { WebType, WidgetData, } from "../types/screen"; + import { generateId } from "../utils/generateId"; +// 화면 복사 요청 인터페이스 +interface CopyScreenRequest { + screenName: string; + screenCode: string; + description?: string; + companyCode: string; + createdBy: string; +} + // 백엔드에서 사용할 테이블 정보 타입 interface TableInfo { tableName: string; @@ -968,6 +978,120 @@ export class ScreenManagementService { return `${companyCode}_${paddedNumber}`; } + + /** + * 화면 복사 (화면 정보 + 레이아웃 모두 복사) + */ + async copyScreen( + sourceScreenId: number, + copyData: CopyScreenRequest + ): Promise { + // 트랜잭션으로 처리 + return await prisma.$transaction(async (tx) => { + // 1. 원본 화면 정보 조회 + const sourceScreen = await tx.screen_definitions.findFirst({ + where: { + screen_id: sourceScreenId, + company_code: copyData.companyCode, + }, + }); + + if (!sourceScreen) { + throw new Error("복사할 화면을 찾을 수 없습니다."); + } + + // 2. 화면 코드 중복 체크 + const existingScreen = await tx.screen_definitions.findFirst({ + where: { + screen_code: copyData.screenCode, + company_code: copyData.companyCode, + }, + }); + + if (existingScreen) { + throw new Error("이미 존재하는 화면 코드입니다."); + } + + // 3. 새 화면 생성 + const newScreen = await tx.screen_definitions.create({ + data: { + screen_code: copyData.screenCode, + screen_name: copyData.screenName, + description: copyData.description || sourceScreen.description, + company_code: copyData.companyCode, + table_name: sourceScreen.table_name, + is_active: sourceScreen.is_active, + created_by: copyData.createdBy, + created_date: new Date(), + updated_by: copyData.createdBy, + updated_date: new Date(), + }, + }); + + // 4. 원본 화면의 레이아웃 정보 조회 + const sourceLayouts = await tx.screen_layouts.findMany({ + where: { + screen_id: sourceScreenId, + }, + orderBy: { display_order: "asc" }, + }); + + // 5. 레이아웃이 있다면 복사 + if (sourceLayouts.length > 0) { + try { + // ID 매핑 맵 생성 + const idMapping: { [oldId: string]: string } = {}; + + // 새로운 컴포넌트 ID 미리 생성 + sourceLayouts.forEach((layout) => { + idMapping[layout.component_id] = generateId(); + }); + + // 각 레이아웃 컴포넌트 복사 + for (const sourceLayout of sourceLayouts) { + const newComponentId = idMapping[sourceLayout.component_id]; + const newParentId = sourceLayout.parent_id + ? idMapping[sourceLayout.parent_id] + : null; + + await tx.screen_layouts.create({ + data: { + screen_id: newScreen.screen_id, + component_type: sourceLayout.component_type, + component_id: newComponentId, + parent_id: newParentId, + position_x: sourceLayout.position_x, + position_y: sourceLayout.position_y, + width: sourceLayout.width, + height: sourceLayout.height, + properties: sourceLayout.properties as any, + display_order: sourceLayout.display_order, + created_date: new Date(), + }, + }); + } + } catch (error) { + console.error("레이아웃 복사 중 오류:", error); + // 레이아웃 복사 실패해도 화면 생성은 유지 + } + } + + // 6. 생성된 화면 정보 반환 + return { + screenId: newScreen.screen_id, + screenCode: newScreen.screen_code, + screenName: newScreen.screen_name, + description: newScreen.description || "", + companyCode: newScreen.company_code, + tableName: newScreen.table_name, + isActive: newScreen.is_active, + createdBy: newScreen.created_by || undefined, + createdDate: newScreen.created_date, + updatedBy: newScreen.updated_by || undefined, + updatedDate: newScreen.updated_date, + }; + }); + } } // 서비스 인스턴스 export diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 74412e3b..2f2b76a5 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -514,4 +514,533 @@ export class TableManagementService { return {}; } } + + /** + * 테이블 데이터 조회 (페이징 + 검색) + */ + async getTableData( + tableName: string, + options: { + page: number; + size: number; + search?: Record; + sortBy?: string; + sortOrder?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const { page, size, search = {}, sortBy, sortOrder = "asc" } = options; + const offset = (page - 1) * size; + + logger.info(`테이블 데이터 조회: ${tableName}`, options); + + // WHERE 조건 구성 + let whereConditions: string[] = []; + let searchValues: any[] = []; + let paramIndex = 1; + + if (search && Object.keys(search).length > 0) { + for (const [column, value] of Object.entries(search)) { + if (value !== null && value !== undefined && value !== "") { + // 안전한 컬럼명 검증 (SQL 인젝션 방지) + const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); + + if (typeof value === "string") { + whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`); + searchValues.push(`%${value}%`); + } else { + whereConditions.push(`${safeColumn} = $${paramIndex}`); + searchValues.push(value); + } + paramIndex++; + } + } + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // ORDER BY 조건 구성 + let orderClause = ""; + if (sortBy) { + const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, ""); + const safeSortOrder = + sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; + orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; + } + + // 안전한 테이블명 검증 + const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; + const countResult = await prisma.$queryRawUnsafe( + countQuery, + ...searchValues + ); + const total = parseInt(countResult[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${safeTableName} + ${whereClause} + ${orderClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await prisma.$queryRawUnsafe( + dataQuery, + ...searchValues, + size, + offset + ); + + const totalPages = Math.ceil(total / size); + + logger.info( + `테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환` + ); + + return { + data, + total, + page, + size, + totalPages, + }; + } catch (error) { + logger.error(`테이블 데이터 조회 오류: ${tableName}`, error); + throw error; + } + } + + /** + * 현재 사용자 정보 조회 (JWT 토큰에서) + */ + private getCurrentUserFromRequest(req?: any): { + userId: string; + userName: string; + } { + // 실제 프로젝트에서는 req 객체에서 JWT 토큰을 파싱하여 사용자 정보를 가져올 수 있습니다 + // 현재는 기본값을 반환 + return { + userId: "system", + userName: "시스템 사용자", + }; + } + + /** + * 값을 PostgreSQL 타입에 맞게 변환 + */ + private convertValueForPostgreSQL(value: any, dataType: string): any { + if (value === null || value === undefined || value === "") { + return null; + } + + const lowerDataType = dataType.toLowerCase(); + + // 날짜/시간 타입 처리 + if ( + lowerDataType.includes("timestamp") || + lowerDataType.includes("datetime") + ) { + // YYYY-MM-DDTHH:mm:ss 형식을 PostgreSQL timestamp로 변환 + if (typeof value === "string") { + try { + const date = new Date(value); + return date.toISOString(); + } catch { + return null; + } + } + return value; + } + + // 날짜 타입 처리 + if (lowerDataType.includes("date")) { + if (typeof value === "string") { + try { + // YYYY-MM-DD 형식 유지 + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + const date = new Date(value); + return date.toISOString().split("T")[0]; + } catch { + return null; + } + } + return value; + } + + // 시간 타입 처리 + if (lowerDataType.includes("time")) { + if (typeof value === "string") { + // HH:mm:ss 형식 유지 + if (/^\d{2}:\d{2}:\d{2}$/.test(value)) { + return value; + } + } + return value; + } + + // 숫자 타입 처리 + if ( + lowerDataType.includes("integer") || + lowerDataType.includes("bigint") || + lowerDataType.includes("serial") + ) { + return parseInt(value) || null; + } + + if ( + lowerDataType.includes("numeric") || + lowerDataType.includes("decimal") || + lowerDataType.includes("real") || + lowerDataType.includes("double") + ) { + return parseFloat(value) || null; + } + + // 불린 타입 처리 + if (lowerDataType.includes("boolean")) { + if (typeof value === "string") { + return value.toLowerCase() === "true" || value === "1"; + } + return Boolean(value); + } + + // 기본적으로 문자열로 처리 + return value; + } + + /** + * 테이블에 데이터 추가 + */ + async addTableData( + tableName: string, + data: Record + ): Promise { + try { + logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); + logger.info(`추가할 데이터:`, data); + + // 테이블의 컬럼 정보 조회 + const columnInfoQuery = ` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `; + + const columnInfoResult = (await prisma.$queryRawUnsafe( + columnInfoQuery, + tableName + )) as any[]; + const columnTypeMap = new Map(); + + columnInfoResult.forEach((col: any) => { + columnTypeMap.set(col.column_name, col.data_type); + }); + + logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); + + // 컬럼명과 값을 분리하고 타입에 맞게 변환 + const columns = Object.keys(data); + const values = Object.values(data).map((value, index) => { + const columnName = columns[index]; + const dataType = columnTypeMap.get(columnName) || "text"; + const convertedValue = this.convertValueForPostgreSQL(value, dataType); + logger.info( + `컬럼 "${columnName}" (${dataType}): "${value}" → "${convertedValue}"` + ); + return convertedValue; + }); + + // 동적 INSERT 쿼리 생성 (타입 캐스팅 포함) + const placeholders = columns + .map((col, index) => { + const dataType = columnTypeMap.get(col) || "text"; + const lowerDataType = dataType.toLowerCase(); + + // PostgreSQL에서 직접 타입 캐스팅 + if ( + lowerDataType.includes("timestamp") || + lowerDataType.includes("datetime") + ) { + return `$${index + 1}::timestamp`; + } else if (lowerDataType.includes("date")) { + return `$${index + 1}::date`; + } else if (lowerDataType.includes("time")) { + return `$${index + 1}::time`; + } else if ( + lowerDataType.includes("integer") || + lowerDataType.includes("bigint") || + lowerDataType.includes("serial") + ) { + return `$${index + 1}::integer`; + } else if ( + lowerDataType.includes("numeric") || + lowerDataType.includes("decimal") + ) { + return `$${index + 1}::numeric`; + } else if (lowerDataType.includes("boolean")) { + return `$${index + 1}::boolean`; + } + + return `$${index + 1}`; + }) + .join(", "); + const columnNames = columns.map((col) => `"${col}"`).join(", "); + + const query = ` + INSERT INTO "${tableName}" (${columnNames}) + VALUES (${placeholders}) + `; + + logger.info(`실행할 쿼리: ${query}`); + logger.info(`쿼리 파라미터:`, values); + + await prisma.$queryRawUnsafe(query, ...values); + + logger.info(`테이블 데이터 추가 완료: ${tableName}`); + } catch (error) { + logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); + throw error; + } + } + + /** + * 테이블 데이터 수정 + */ + async editTableData( + tableName: string, + originalData: Record, + updatedData: Record + ): Promise { + try { + logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`); + logger.info(`원본 데이터:`, originalData); + logger.info(`수정할 데이터:`, updatedData); + + // 테이블의 컬럼 정보 조회 (PRIMARY KEY 찾기용) + const columnInfoQuery = ` + SELECT c.column_name, c.data_type, c.is_nullable, + CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' ELSE 'NO' END as is_primary_key + FROM information_schema.columns c + LEFT JOIN information_schema.key_column_usage kcu ON c.column_name = kcu.column_name AND c.table_name = kcu.table_name + LEFT JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.table_name = c.table_name + WHERE c.table_name = $1 + ORDER BY c.ordinal_position + `; + + const columnInfoResult = (await prisma.$queryRawUnsafe( + columnInfoQuery, + tableName + )) as any[]; + const columnTypeMap = new Map(); + const primaryKeys: string[] = []; + + columnInfoResult.forEach((col: any) => { + columnTypeMap.set(col.column_name, col.data_type); + if (col.is_primary_key === "YES") { + primaryKeys.push(col.column_name); + } + }); + + logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); + logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys); + + // SET 절 생성 (수정할 데이터) - 먼저 생성 + const setConditions: string[] = []; + const setValues: any[] = []; + let paramIndex = 1; + + Object.keys(updatedData).forEach((column) => { + const dataType = columnTypeMap.get(column) || "text"; + setConditions.push( + `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` + ); + setValues.push( + this.convertValueForPostgreSQL(updatedData[column], dataType) + ); + paramIndex++; + }); + + // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) + let whereConditions: string[] = []; + let whereValues: any[] = []; + + if (primaryKeys.length > 0) { + // PRIMARY KEY로 WHERE 조건 생성 + primaryKeys.forEach((pkColumn) => { + if (originalData[pkColumn] !== undefined) { + const dataType = columnTypeMap.get(pkColumn) || "text"; + whereConditions.push( + `"${pkColumn}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` + ); + whereValues.push( + this.convertValueForPostgreSQL(originalData[pkColumn], dataType) + ); + paramIndex++; + } + }); + } else { + // PRIMARY KEY가 없으면 모든 원본 데이터로 WHERE 조건 생성 + Object.keys(originalData).forEach((column) => { + const dataType = columnTypeMap.get(column) || "text"; + whereConditions.push( + `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` + ); + whereValues.push( + this.convertValueForPostgreSQL(originalData[column], dataType) + ); + paramIndex++; + }); + } + + // UPDATE 쿼리 생성 + const query = ` + UPDATE "${tableName}" + SET ${setConditions.join(", ")} + WHERE ${whereConditions.join(" AND ")} + `; + + const allValues = [...setValues, ...whereValues]; + + logger.info(`실행할 UPDATE 쿼리: ${query}`); + logger.info(`쿼리 파라미터:`, allValues); + + const result = await prisma.$queryRawUnsafe(query, ...allValues); + + logger.info(`테이블 데이터 수정 완료: ${tableName}`, result); + } catch (error) { + logger.error(`테이블 데이터 수정 오류: ${tableName}`, error); + throw error; + } + } + + /** + * PostgreSQL 타입명 반환 + */ + private getPostgreSQLType(dataType: string): string { + const lowerDataType = dataType.toLowerCase(); + + if ( + lowerDataType.includes("timestamp") || + lowerDataType.includes("datetime") + ) { + return "timestamp"; + } else if (lowerDataType.includes("date")) { + return "date"; + } else if (lowerDataType.includes("time")) { + return "time"; + } else if ( + lowerDataType.includes("integer") || + lowerDataType.includes("bigint") || + lowerDataType.includes("serial") + ) { + return "integer"; + } else if ( + lowerDataType.includes("numeric") || + lowerDataType.includes("decimal") + ) { + return "numeric"; + } else if (lowerDataType.includes("boolean")) { + return "boolean"; + } + + return "text"; // 기본값 + } + + /** + * 테이블에서 데이터 삭제 + */ + async deleteTableData( + tableName: string, + dataToDelete: Record[] + ): Promise { + try { + logger.info(`테이블 데이터 삭제: ${tableName}`, dataToDelete); + + if (!Array.isArray(dataToDelete) || dataToDelete.length === 0) { + throw new Error("삭제할 데이터가 없습니다."); + } + + let deletedCount = 0; + + // 테이블의 기본 키 컬럼 찾기 (정확한 식별을 위해) + const primaryKeyQuery = ` + SELECT column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE tc.table_name = $1 + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position + `; + + const primaryKeys = await prisma.$queryRawUnsafe< + { column_name: string }[] + >(primaryKeyQuery, tableName); + + if (primaryKeys.length === 0) { + // 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성 + logger.warn( + `테이블 ${tableName}에 기본 키가 없습니다. 모든 컬럼으로 삭제 조건을 생성합니다.` + ); + + for (const rowData of dataToDelete) { + const conditions = Object.keys(rowData) + .map((key, index) => `"${key}" = $${index + 1}`) + .join(" AND "); + + const values = Object.values(rowData); + + const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`; + + const result = await prisma.$queryRawUnsafe(deleteQuery, ...values); + deletedCount += Number(result); + } + } else { + // 기본 키를 사용한 삭제 + const primaryKeyNames = primaryKeys.map((pk) => pk.column_name); + + for (const rowData of dataToDelete) { + const conditions = primaryKeyNames + .map((key, index) => `"${key}" = $${index + 1}`) + .join(" AND "); + + const values = primaryKeyNames.map((key) => rowData[key]); + + // null 값이 있는 경우 스킵 + if (values.some((val) => val === null || val === undefined)) { + logger.warn(`기본 키 값이 null인 행을 스킵합니다:`, rowData); + continue; + } + + const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`; + + const result = await prisma.$queryRawUnsafe(deleteQuery, ...values); + deletedCount += Number(result); + } + } + + logger.info( + `테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제` + ); + return deletedCount; + } catch (error) { + logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error); + throw error; + } + } } diff --git a/docs/화면관리_시스템_설계.md b/docs/화면관리_시스템_설계.md index abb262ac..34fa3ccc 100644 --- a/docs/화면관리_시스템_설계.md +++ b/docs/화면관리_시스템_설계.md @@ -30,21 +30,36 @@ - **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능 - **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리 -### 🆕 최근 업데이트 (요약) +### 🆕 최근 업데이트 (2024.12) -- **픽셀 기반 자유 이동**: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지 -- **멀티 선택 강화**: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외 -- **다중 드래그 이동**: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거 -- **그룹 UI 간소화**: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중 -- **그룹 내 정렬/분배 툴**: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI) -- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화 -- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y) -- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시 -- **복사/삭제/붙여넣기 범용화**: 단일/다중/그룹 선택 모두에서 동작. 우클릭 위치 붙여넣기, 단축키(Ctrl/Cmd+C, Ctrl/Cmd+V, Delete) 지원, 상단 툴바 버튼 제공. 클립보드 상태 배지 표시(예: "3개 복사됨", "그룹 복사됨") -- **화면 코드 자동 생성**: 회사 코드 기반 고유 화면 코드 자동 생성 (예: COMP_001) -- **레이아웃 저장/로드**: 설계한 화면 레이아웃을 데이터베이스에 저장하고 불러오는 기능 -- **메뉴-화면 할당**: 설계한 화면을 실제 메뉴에 할당하여 사용자가 접근할 수 있도록 연결 -- **인터랙티브 화면 뷰어**: 할당된 화면에서 실제 사용자 입력 및 상호작용이 가능한 완전 기능 화면 +#### ✅ 완료된 주요 기능들 + +- **컴포넌트 관리 시스템**: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트 +- **⚡ 실시간 속성 편집 시스템**: 로컬 상태 기반 즉시 반영, 완벽한 입력/체크박스 실시간 업데이트 +- **속성 편집 시스템**: 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시 +- **격자 시스템**: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정 +- **패널 관리**: 플로팅 패널, 수동 크기 조정, 위치 기억 +- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file +- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징 +- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅 + +#### 🔧 해결된 기술적 문제들 + +- **⚡ 실시간 속성 편집 완성**: 로컬 상태 기반 이중 관리 시스템으로 완벽한 실시간 반영 +- **체크박스 실시간 업데이트**: 모든 체크박스의 즉시 상태 변경 및 유지 +- **동적 컴포넌트 상태 관리**: ID 기반 컬럼별 개별 상태 관리 및 동기화 +- **라벨 하단 여백 동적 적용**: 여백값에 따른 정확한 위치 계산 +- **스타일 속성 개별 업데이트**: 초기화 방지를 위한 `style.propertyName` 방식 적용 +- **다중 드래그 최적화**: 지연 없는 실시간 미리보기, 선택 해제 방지 +- **입력값 보존 시스템**: 패널 재오픈해도 사용자 입력값 완벽 유지 + +#### 🎯 개발 진행 상황 + +- **현재 완성도**: 98% (실시간 편집 시스템 완성, 핵심 기능 완료) +- **기술 스택**: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui +- **⚡ 상태 관리**: **완성된 실시간 속성 편집 패턴** - 로컬 상태 + 글로벌 상태 이중 관리 +- **드래그앤드롭**: HTML5 Drag & Drop API 기반 고도화된 시스템 +- **🎯 표준화**: 모든 속성 편집 컴포넌트에 실시간 패턴 적용 완료 ### 🎯 **현재 테이블 구조와 100% 호환** @@ -173,6 +188,187 @@ 4. **설정 저장**: 화면 정의를 데이터베이스에 저장 5. **런타임 생성**: 실제 서비스 화면을 동적으로 생성 +## ⚡ 실시간 속성 편집 시스템 + +### 개요 + +화면관리 시스템의 핵심 기능 중 하나인 실시간 속성 편집은 사용자가 컴포넌트의 속성을 수정할 때 즉시 화면에 반영되는 시스템입니다. 이 시스템은 **로컬 상태 기반 입력 관리**와 **실시간 업데이트 패턴**을 통해 구현되었습니다. + +### 🎯 핵심 아키텍처 패턴 + +#### 1. 로컬 상태 + 글로벌 상태 이중 관리 + +```typescript +// 1단계: 로컬 상태 정의 (실시간 표시용) +const [localInputs, setLocalInputs] = useState({ + title: component.title || "", + placeholder: component.placeholder || "", + // 모든 입력 필드의 현재 값 +}); + +const [localValues, setLocalValues] = useState({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + // 모든 체크박스의 현재 상태 +}); + +// 2단계: 컴포넌트 변경 시 자동 동기화 +useEffect(() => { + setLocalInputs({ + title: component.title || "", + placeholder: component.placeholder || "", + }); + + setLocalValues({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + }); +}, [component.title, component.placeholder, component.showButton]); +``` + +#### 2. 실시간 입력 처리 패턴 + +```typescript +// 텍스트 입력 - 즉시 반영 + { + const newValue = e.target.value; + // 1) 로컬 상태 즉시 업데이트 (화면 반영) + setLocalInputs(prev => ({ ...prev, title: newValue })); + // 2) 글로벌 상태 업데이트 (데이터 저장) + onUpdateProperty("title", newValue); + }} +/> + +// 체크박스 - 즉시 반영 + { + // 1) 로컬 상태 즉시 업데이트 + setLocalValues(prev => ({ ...prev, showButton: checked as boolean })); + // 2) 글로벌 상태 업데이트 + onUpdateProperty("showButton", checked); + }} +/> +``` + +#### 3. 동적 컴포넌트별 상태 관리 + +```typescript +// 컬럼별 개별 상태 관리 (ID 기반) +const [localColumnInputs, setLocalColumnInputs] = useState< + Record +>({}); +const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState< + Record +>({}); + +// 기존 값 보존하면서 새 항목만 추가 +useEffect(() => { + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + component.columns?.forEach((col) => { + if (!(col.id in newInputs)) { + // 기존 입력값 보존 + newInputs[col.id] = col.label; + } + }); + return newInputs; + }); +}, [component.columns]); + +// 동적 입력 처리 + { + const newValue = e.target.value; + setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue })); + updateColumn(column.id, { label: newValue }); + }} +/>; +``` + +### 🔧 구현 표준 가이드라인 + +#### 필수 구현 패턴 + +1. **로컬 우선 원칙**: 모든 입력은 로컬 상태를 먼저 업데이트 +2. **즉시 반영**: 로컬 상태 업데이트와 동시에 컴포넌트 속성 업데이트 +3. **기존값 보존**: useEffect에서 기존 로컬 입력값이 있으면 덮어쓰지 않음 +4. **완전한 정리**: 항목 삭제 시 관련된 모든 로컬 상태도 함께 정리 +5. **타입 안전성**: 모든 상태에 정확한 TypeScript 타입 지정 + +#### 항목 추가/삭제 시 상태 관리 + +```typescript +// 추가 시 +const addItem = useCallback( + (newItem) => { + // 로컬 상태에 즉시 추가 + setLocalColumnInputs((prev) => ({ + ...prev, + [newItem.id]: newItem.label, + })); + + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [newItem.id]: { visible: true, sortable: true, searchable: true }, + })); + + // 실제 컴포넌트 업데이트 + onUpdateComponent({ items: [...component.items, newItem] }); + }, + [component.items, onUpdateComponent] +); + +// 삭제 시 +const removeItem = useCallback( + (itemId) => { + // 로컬 상태에서 제거 + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + delete newInputs[itemId]; + return newInputs; + }); + + setLocalColumnCheckboxes((prev) => { + const newCheckboxes = { ...prev }; + delete newCheckboxes[itemId]; + return newCheckboxes; + }); + + // 실제 컴포넌트 업데이트 + const updatedItems = component.items.filter((item) => item.id !== itemId); + onUpdateComponent({ items: updatedItems }); + }, + [component.items, onUpdateComponent] +); +``` + +### 📊 적용 범위 + +이 패턴은 화면관리 시스템의 다음 컴포넌트들에 적용되었습니다: + +- **PropertiesPanel**: 기본 속성 편집 (위치, 크기, 라벨 등) +- **DataTableConfigPanel**: 데이터 테이블 상세 설정 +- **DateTypeConfigPanel**: 날짜 타입 상세 설정 +- **NumberTypeConfigPanel**: 숫자 타입 상세 설정 +- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정 +- **TextTypeConfigPanel**: 텍스트 타입 상세 설정 +- **기타 모든 웹타입별 설정 패널들** + +### 🎯 사용자 경험 향상 효과 + +- **🚀 즉시 피드백**: 타이핑하는 순간 화면에 바로 반영 +- **🔄 상태 일관성**: 패널을 닫았다 열어도 입력한 값이 정확히 유지 +- **⚡ 빠른 반응성**: 지연 없는 실시간 UI 업데이트 +- **🛡️ 안정성**: 메모리 누수 없는 완전한 상태 관리 + ## 🚀 핵심 기능 ### 1. 화면 설계기 (Screen Designer) @@ -1751,6 +1947,271 @@ export default function GridItem({ ## ⚙️ 백엔드 구현 +### 🆕 데이터 테이블 실시간 조회 API (2025.09 추가) + +#### 1. 테이블 데이터 조회 API + +**라우트 설정** + +```typescript +// tableManagementRoutes.ts +/** + * 테이블 데이터 조회 (페이징 + 검색) + * POST /api/table-management/tables/:tableName/data + */ +router.post("/tables/:tableName/data", getTableData); +``` + +**컨트롤러 구현** + +```typescript +// tableManagementController.ts +export async function getTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { + page = 1, + size = 10, + search = {}, + sortBy, + sortOrder = "asc", + } = req.body; + + logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); + logger.info(`페이징: page=${page}, size=${size}`); + logger.info(`검색 조건:`, search); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { code: "MISSING_TABLE_NAME" }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 데이터 조회 + const result = await tableManagementService.getTableData(tableName, { + page: parseInt(page), + size: parseInt(size), + search, + sortBy, + sortOrder, + }); + + const response: ApiResponse = { + success: true, + message: "테이블 데이터를 성공적으로 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 데이터 조회 중 오류 발생:", error); + // 오류 응답 처리 + } +} +``` + +**서비스 로직** + +```typescript +// tableManagementService.ts +export class TableManagementService { + /** + * 테이블 데이터 조회 (페이징 + 검색) + */ + async getTableData( + tableName: string, + options: { + page: number; + size: number; + search?: Record; + sortBy?: string; + sortOrder?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const { page, size, search = {}, sortBy, sortOrder = "asc" } = options; + const offset = (page - 1) * size; + + // WHERE 조건 구성 (SQL 인젝션 방지) + let whereConditions: string[] = []; + let searchValues: any[] = []; + let paramIndex = 1; + + if (search && Object.keys(search).length > 0) { + for (const [column, value] of Object.entries(search)) { + if (value !== null && value !== undefined && value !== "") { + // 안전한 컬럼명 검증 + const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); + + if (typeof value === "string") { + whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`); + searchValues.push(`%${value}%`); + } else { + whereConditions.push(`${safeColumn} = $${paramIndex}`); + searchValues.push(value); + } + paramIndex++; + } + } + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // ORDER BY 조건 구성 + let orderClause = ""; + if (sortBy) { + const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, ""); + const safeSortOrder = + sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; + orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; + } + + // 안전한 테이블명 검증 + const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; + const countResult = await prisma.$queryRawUnsafe( + countQuery, + ...searchValues + ); + const total = parseInt(countResult[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${safeTableName} + ${whereClause} + ${orderClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await prisma.$queryRawUnsafe( + dataQuery, + ...searchValues, + size, + offset + ); + + const totalPages = Math.ceil(total / size); + + return { + data, + total, + page, + size, + totalPages, + }; + } catch (error) { + logger.error(`테이블 데이터 조회 오류: ${tableName}`, error); + throw error; + } + } +} +``` + +#### 2. API 응답 형식 + +**성공 응답** + +```json +{ + "success": true, + "message": "테이블 데이터를 성공적으로 조회했습니다.", + "data": { + "data": [ + { + "objid": 1, + "target_objid": 12345, + "approval_seq": "A001", + "regdate": "2025-09-03T05:39:14.000Z", + "status": "pending" + } + ], + "total": 1, + "page": 1, + "size": 5, + "totalPages": 1 + } +} +``` + +**요청 형식** + +```json +{ + "page": 1, + "size": 10, + "search": { + "approval_seq": "A001", + "status": "pending" + }, + "sortBy": "regdate", + "sortOrder": "desc" +} +``` + +#### 3. 보안 및 성능 최적화 + +**SQL 인젝션 방지** + +- 정규표현식을 통한 안전한 컬럼명/테이블명 검증 +- 파라미터 바인딩 사용 (`$queryRawUnsafe` with parameters) +- 사용자 입력값 필터링 + +**성능 최적화** + +- 페이징 처리로 대용량 데이터 대응 +- COUNT 쿼리와 데이터 쿼리 분리 +- 인덱스 기반 정렬 지원 + +**에러 처리** + +- 상세한 로깅 시스템 +- 사용자 친화적 오류 메시지 +- HTTP 상태 코드 준수 + +#### 4. 도커 환경 통합 + +**개발 환경 설정** + +```bash +# 백엔드 컨테이너 재빌드 (새 API 반영) +docker-compose -f docker/dev/docker-compose.backend.mac.yml down backend +docker-compose -f docker/dev/docker-compose.backend.mac.yml up --build -d backend + +# API 테스트 +curl -X POST http://localhost:8080/api/table-management/tables/approval/data \ + -H "Content-Type: application/json" \ + -d '{"page": 1, "size": 5}' +``` + +**환경 변수** + +```env +# backend-node/.env +PORT=8080 +DATABASE_URL=postgresql://postgres:password@localhost:5432/ilshin +NODE_ENV=development +``` + ### 1. 화면 관리 서비스 ```typescript @@ -2367,9 +2828,412 @@ export class TableTypeIntegrationService { - **저장 기능**: 입력된 데이터를 수집하여 저장 처리 - **메뉴 연동**: 메뉴 클릭 시 할당된 인터랙티브 화면으로 자동 이동 +### 🆕 6. 실시간 데이터 테이블 (2025.09 추가) + +#### **InteractiveDataTable 컴포넌트** + +실제 화면에서 동작하는 완전한 데이터 테이블 구현 + +**핵심 기능** + +- **실시간 데이터 조회**: PostgreSQL 데이터베이스에서 직접 데이터 로드 +- **페이지네이션**: 대용량 데이터 효율적 탐색 (페이지당 항목 수 설정 가능) +- **다중 검색 필터**: 웹타입별 맞춤형 검색 UI (text, number, date, select 등) +- **정렬 기능**: 컬럼별 오름차순/내림차순 정렬 지원 +- **반응형 레이아웃**: 격자 시스템 기반 컬럼 너비 조정 + +**구현 코드** + +```typescript +// InteractiveDataTable.tsx +export const InteractiveDataTable: React.FC = ({ + component, + className = "", + style = {}, +}) => { + const [data, setData] = useState[]>([]); + const [loading, setLoading] = useState(false); + const [searchValues, setSearchValues] = useState>({}); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + + // 데이터 로드 함수 + const loadData = useCallback( + async (page: number = 1, searchParams: Record = {}) => { + if (!component.tableName) return; + + setLoading(true); + try { + const result = await tableTypeApi.getTableData(component.tableName, { + page, + size: component.pagination?.pageSize || 10, + search: searchParams, + }); + + setData(result.data); + setTotal(result.total); + setTotalPages(result.totalPages); + setCurrentPage(result.page); + } catch (error) { + console.error("❌ 테이블 데이터 조회 실패:", error); + setData([]); + } finally { + setLoading(false); + } + }, + [component.tableName, component.pagination?.pageSize] + ); + + // 검색 실행 + const handleSearch = useCallback(() => { + loadData(1, searchValues); + }, [searchValues, loadData]); + + return ( +
+ {/* 헤더: 제목, 로딩 상태, 검색 버튼 */} +
+
+

+ {component.title || component.label} + {loading && ( + + 로딩중... + + )} +

+
+ {component.showSearchButton && ( + + )} +
+
+ + {/* 검색 필터 */} + {component.filters?.length > 0 && ( +
+
검색 필터
+
`${filter.gridColumns || 3}fr`) + .join(" "), + }} + > + {component.filters.map((filter) => renderSearchFilter(filter))} +
+
+ )} +
+ + {/* 테이블 데이터 */} +
+ {/* 헤더 행 */} +
col.visible) + .map((col) => `${col.gridColumns || 2}fr`) + .join(" "), + }} + > + {component.columns + ?.filter((col) => col.visible) + .map((column) => ( +
+ {column.label} +
+ ))} +
+ + {/* 데이터 행들 */} + {loading ? ( +
+ 데이터를 불러오는 중... +
+ ) : data.length > 0 ? ( + data.map((row, rowIndex) => ( +
col.visible) + .map((col) => `${col.gridColumns || 2}fr`) + .join(" "), + }} + > + {component.columns + ?.filter((col) => col.visible) + .map((column) => ( +
+ {formatCellValue(row[column.columnName], column)} +
+ ))} +
+ )) + ) : ( +
+ 검색 결과가 없습니다 +
+ )} +
+ + {/* 페이지네이션 */} + {component.pagination?.enabled && totalPages > 1 && ( +
+
+ {component.pagination.showPageInfo && ( +
+ 총 {total.toLocaleString()}개 중{" "} + {((currentPage - 1) * pageSize + 1).toLocaleString()}- + {Math.min(currentPage * pageSize, total).toLocaleString()} +
+ )} +
+ + + {currentPage} / {totalPages} + + +
+
+
+ )} +
+ ); +}; +``` + +**데이터 포맷팅** + +```typescript +// 셀 값 포맷팅 +const formatCellValue = (value: any, column: DataTableColumn) => { + if (value === null || value === undefined) return ""; + + switch (column.widgetType) { + case "date": + if (value) { + try { + const date = new Date(value); + return date.toLocaleDateString("ko-KR"); + } catch { + return value; + } + } + break; + + case "datetime": + if (value) { + try { + const date = new Date(value); + return date.toLocaleString("ko-KR"); + } catch { + return value; + } + } + break; + + case "number": + case "decimal": + if (typeof value === "number") { + return value.toLocaleString(); + } + break; + + default: + return String(value); + } + + return String(value); +}; +``` + +**InteractiveScreenViewer 통합** + +```typescript +// InteractiveScreenViewer.tsx +const renderInteractiveWidget = (comp: ComponentData) => { + // 데이터 테이블 컴포넌트 처리 + if (comp.type === "datatable") { + return ( + + ); + } + + // 기존 위젯 처리... +}; +``` + +#### **사용자 경험 (UX)** + +**1. 관리자 (화면 설계)** + +1. 드래그앤드롭으로 데이터 테이블 컴포넌트 배치 +2. 속성 패널에서 테이블 선택 및 컬럼 설정 +3. 검색 필터 추가 및 페이지네이션 설정 +4. 실시간 미리보기로 결과 확인 + +**2. 사용자 (실제 화면)** + +1. 메뉴를 통해 할당된 화면 접근 +2. 검색 필터로 원하는 데이터 필터링 +3. 페이지네이션으로 대용량 데이터 탐색 +4. 실시간 데이터 로딩 및 정렬 + +#### **🎨 Shadcn UI 기반 모던 디자인** + +**핵심 컴포넌트** + +- **Card**: 전체 테이블을 감싸는 메인 컨테이너 +- **Table**: Shadcn Table 컴포넌트로 표준화된 테이블 UI +- **Badge**: 로딩 상태 및 필터 개수 표시 +- **Button**: 일관된 액션 버튼 디자인 +- **Separator**: 섹션 구분선 + +**디자인 특징** + +```typescript +// 메인 카드 레이아웃 + + + {/* 아이콘 + 제목 + 액션 버튼들 */} +
+ + {title} + {loading && ( + + + 로딩중... + + )} +
+
+ + {/* Shadcn Table 사용 */} + + + + + {columns.map((column) => ( + {column.label} + ))} + + + {/* 로딩, 데이터, 빈 상태 처리 */} +
+
+
+``` + +**시각적 개선사항** + +- **아이콘 통합**: Lucide React 아이콘으로 시각적 일관성 +- **로딩 애니메이션**: 스피너 아이콘으로 실시간 피드백 +- **상태별 메시지**: 빈 데이터, 로딩, 에러 상태별 적절한 UI +- **호버 효과**: 테이블 행 호버 시 시각적 피드백 +- **반응형 버튼**: 아이콘 + 텍스트 조합으로 명확한 액션 표시 + +#### **기술적 특징** + +- **성능 최적화**: React.useMemo를 활용한 메모이제이션 +- **보안**: SQL 인젝션 방지 및 입력값 검증 +- **확장성**: 웹타입별 검색 필터 및 데이터 포맷터 +- **반응형**: CSS Grid 기반 유연한 레이아웃 +- **접근성**: Shadcn UI의 WAI-ARIA 표준 준수 +- **타입 안전성**: TypeScript 완전 지원 + ## 🚀 다음 단계 계획 -### 1. 컴포넌트 그룹화 기능 (완료) +### 1. 웹타입별 상세 설정 기능 구현 (진행 예정) + +#### 📋 구현 계획 개요 + +각 웹 타입(date, number, select 등)에 대한 세부적인 설정을 가능하게 하여 더 정교한 폼 컨트롤을 제공 + +#### 🎯 단계별 구현 계획 + +##### Phase 1: 타입 정의 및 인터페이스 설계 + +```typescript +// 웹타입별 설정 인터페이스 +interface DateTypeConfig { + format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss"; + showTime: boolean; + minDate?: string; + maxDate?: string; + defaultValue?: string; +} + +interface NumberTypeConfig { + min?: number; + max?: number; + step?: number; + format?: "integer" | "decimal" | "currency" | "percentage"; + decimalPlaces?: number; + thousandSeparator?: boolean; +} + +interface SelectTypeConfig { + options: Array<{ label: string; value: string }>; + multiple?: boolean; + searchable?: boolean; + placeholder?: string; +} +``` + +##### Phase 2: PropertiesPanel 확장 + +- 웹 타입 선택 시 해당 타입의 세부 설정 UI 동적 표시 +- 각 타입별 전용 설정 컴포넌트 생성 +- 실시간 설정값 업데이트 및 미리보기 + +##### Phase 3: 우선순위 타입별 구현 + +1. **날짜/시간 (date, datetime)**: 날짜 형식, 시간 포함 여부, 날짜 범위 +2. **숫자 (number, decimal)**: 범위, 형식, 소수점, 천 단위 구분자 +3. **선택박스 (select, dropdown)**: 동적 옵션 관리, 다중 선택, 검색 기능 +4. **텍스트 (text, textarea)**: 길이 제한, 입력 패턴, 형식 검증 +5. **파일 (file)**: 파일 형식 제한, 크기 제한, 다중 업로드 + +##### Phase 4: RealtimePreview 업데이트 + +설정값에 따른 실제 렌더링 로직 구현 (input 속성, 검증 규칙 등) + +##### Phase 5: 저장/불러오기 + +컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장 + +### 2. 컴포넌트 그룹화 기능 (완료)0 - [x] 여러 위젯을 컨테이너로 그룹화 - [x] 부모-자식 관계 설정(parentId) @@ -2463,6 +3327,193 @@ export class TableTypeIntegrationService { - **Version Control**: Git - **Package Manager**: npm +## 🔧 핵심 기술적 구현 패턴 + +### 1. ⚡ 실시간 속성 편집 패턴 (핵심 표준) + +#### 완성된 로컬 상태 + 글로벌 상태 이중 관리 시스템 + +**모든 속성 편집 컴포넌트의 표준 패턴:** + +```typescript +// 1단계: 로컬 상태 정의 (실시간 표시용) +const [localInputs, setLocalInputs] = useState({ + title: component.title || "", + placeholder: component.placeholder || "", + // 모든 입력 필드의 현재 값 +}); + +const [localValues, setLocalValues] = useState({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + // 모든 체크박스의 현재 상태 +}); + +// 2단계: 컴포넌트 변경 시 자동 동기화 +useEffect(() => { + setLocalInputs({ + title: component.title || "", + placeholder: component.placeholder || "", + }); + + setLocalValues({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + }); +}, [component.title, component.placeholder, component.showButton]); + +// 3단계: 실시간 입력 처리 - 즉시 반영 + { + const newValue = e.target.value; + // 1) 로컬 상태 즉시 업데이트 (화면 반영) + setLocalInputs(prev => ({ ...prev, title: newValue })); + // 2) 글로벌 상태 업데이트 (데이터 저장) + onUpdateProperty("title", newValue); + }} +/> + + { + // 1) 로컬 상태 즉시 업데이트 + setLocalValues(prev => ({ ...prev, showButton: checked as boolean })); + // 2) 글로벌 상태 업데이트 + onUpdateProperty("showButton", checked); + }} +/> +``` + +#### 동적 컴포넌트별 상태 관리 (ID 기반) + +```typescript +// 컬럼별 개별 상태 관리 +const [localColumnInputs, setLocalColumnInputs] = useState< + Record +>({}); +const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState< + Record +>({}); + +// 기존 값 보존하면서 새 항목만 추가 +useEffect(() => { + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + component.columns?.forEach((col) => { + if (!(col.id in newInputs)) { + // 기존 입력값 보존 + newInputs[col.id] = col.label; + } + }); + return newInputs; + }); +}, [component.columns]); + +// 동적 입력 처리 + { + const newValue = e.target.value; + setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue })); + updateColumn(column.id, { label: newValue }); + }} +/>; +``` + +#### 스타일 속성 개별 업데이트 패턴 + +스타일 초기화 방지를 위한 개별 속성 업데이트: + +```typescript +// Bad: 전체 객체 교체로 인한 다른 속성 손실 +onUpdateProperty("style", { ...selectedComponent.style, newProp: value }); + +// Good: 개별 속성 직접 업데이트 +onUpdateProperty("style.labelFontSize", value); +``` + +### 📋 적용된 컴포넌트 목록 + +이 패턴이 완벽하게 적용된 컴포넌트들: + +- **PropertiesPanel**: 기본 속성 편집 +- **DataTableConfigPanel**: 데이터 테이블 상세 설정 +- **DateTypeConfigPanel**: 날짜 타입 상세 설정 +- **NumberTypeConfigPanel**: 숫자 타입 상세 설정 +- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정 +- **TextTypeConfigPanel**: 텍스트 타입 상세 설정 +- **기타 모든 웹타입별 설정 패널들** + +### 2. 드래그앤드롭 패턴 + +#### 다중 컴포넌트 드래그 처리 + +- dragState에 draggedComponents 배열로 선택된 모든 컴포넌트 관리 +- 실시간 미리보기를 위한 RealtimePreview와 실제 업데이트 분리 +- justFinishedDrag 플래그로 드래그 완료 후 의도치 않은 선택 해제 방지 + +#### 격자 스냅 시스템 + +- 컴포넌트 위치와 크기를 격자에 맞게 자동 조정 +- 격자 설정 변경 시 기존 컴포넌트들도 자동 재조정 + +### 3. 컴포넌트 렌더링 패턴 + +#### 웹타입별 동적 렌더링 + +RealtimePreview에서 switch-case로 웹타입별 적절한 입력 컴포넌트 렌더링: + +```typescript +switch (widgetType) { + case "text": + return ; + case "date": + return ; + case "select": + return ; +} +``` + +#### 라벨 동적 위치 계산 + +라벨 하단 여백 설정에 따른 동적 위치 계산: + +```typescript +const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10); +style={{ top: `${-20 - labelMarginBottomValue}px` }} +``` + +### 4. 패널 관리 패턴 + +#### 플로팅 패널 상태 관리 + +- 각 패널의 위치, 크기, 열림/닫힘 상태를 독립적으로 관리 +- 사용자가 수동으로 조정한 위치 기억 +- autoHeight 제거로 컨텐츠 변경 시에도 위치 유지 + +### 5. 타입 안전성 패턴 + +#### 인터페이스 확장 패턴 + +BaseComponent를 기본으로 각 컴포넌트 타입별 확장: + +```typescript +export interface WidgetComponent extends BaseComponent { + type: "widget"; + widgetType: WebType; + // 위젯 전용 속성들 +} +``` + +#### 유니온 타입 활용 + +ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로 타입 안전성 보장 + ## 🎯 결론 화면관리 시스템은 **회사별 권한 관리**와 **테이블 타입관리 연계**를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다. @@ -2493,7 +3544,8 @@ export class TableTypeIntegrationService { - ✅ **Phase 1-6 완료**: 기본 구조, 드래그앤드롭, 컴포넌트 라이브러리, 테이블 연계, 미리보기, 통합 테스트 - ✅ **핵심 기능 완료**: 컴포넌트 그룹화, 레이아웃 저장/로드, 메뉴-화면 할당, 인터랙티브 화면 뷰어 -- 📋 **향후 계획**: 반응형 레이아웃, 고급 기능, 실제 CRUD 연동 +- ✅ **고도화 완료**: 실시간 속성 편집, 라벨 관리, 다중 드래그, 격자 시스템 +- 📋 **다음 계획**: 웹타입별 상세 설정, 반응형 레이아웃, 고급 기능 ### 🎉 **완전 기능 화면관리 시스템 완성!** diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx index 0b481e39..bf90f2d7 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } from "lucide-react"; +import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; @@ -62,69 +62,11 @@ export default function ScreenManagementPage() { } }; - // 단계별 진행 상태 확인 - const isStepCompleted = (step: Step) => { - return stepHistory.includes(step); - }; - // 현재 단계가 마지막 단계인지 확인 const isLastStep = currentStep === "template"; return (
- {/* 페이지 헤더 */} -
-
-

화면관리 시스템

-

단계별로 화면을 관리하고 설계하세요

-
-
{stepConfig[currentStep].description}
-
- - {/* 단계별 진행 표시 */} -
-
- {Object.entries(stepConfig).map(([step, config], index) => ( -
-
- -
-
- {config.title} -
-
-
- {index < Object.keys(stepConfig).length - 1 && ( -
- )} -
- ))} -
-
- {/* 단계별 내용 */}
{/* 화면 목록 단계 */} diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 7963e380..24e9fd69 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -2,10 +2,8 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Save, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData } from "@/types/screen"; import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer"; @@ -21,7 +19,6 @@ export default function ScreenViewPage() { const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); const [formData, setFormData] = useState>({}); useEffect(() => { @@ -59,30 +56,9 @@ export default function ScreenViewPage() { } }, [screenId]); - // 폼 데이터 저장 함수 - const handleSaveData = async () => { - if (!screen) return; - - try { - setSaving(true); - console.log("저장할 데이터:", formData); - console.log("화면 정보:", screen); - - // 여기에 실제 데이터 저장 API 호출을 추가할 수 있습니다 - // await saveFormData(screen.tableName, formData); - - toast.success("데이터가 성공적으로 저장되었습니다."); - } catch (error) { - console.error("데이터 저장 실패:", error); - toast.error("데이터 저장에 실패했습니다."); - } finally { - setSaving(false); - } - }; - if (loading) { return ( -
+

화면을 불러오는 중...

@@ -93,7 +69,7 @@ export default function ScreenViewPage() { if (error || !screen) { return ( -
+
⚠️ @@ -101,7 +77,6 @@ export default function ScreenViewPage() {

화면을 찾을 수 없습니다

{error || "요청하신 화면이 존재하지 않습니다."}

@@ -110,167 +85,108 @@ export default function ScreenViewPage() { } return ( -
- {/* 헤더 */} -
-
- -
-

{screen.screenName}

-
- - {screen.screenCode} - - - {screen.tableName} - - - {screen.isActive === "Y" ? "활성" : "비활성"} - -
-
-
-
- 생성일: {screen.createdDate.toLocaleDateString()} -
-
+
+ {layout && layout.components.length > 0 ? ( + // 캔버스 컴포넌트들만 표시 - 전체 화면 사용 +
+ {layout.components + .filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함) + .map((component) => { + // 그룹 컴포넌트인 경우 특별 처리 + if (component.type === "group") { + const groupChildren = layout.components.filter((child) => child.parentId === component.id); - {/* 메인 컨텐츠 영역 */} -
- {layout && layout.components.length > 0 ? ( -
- - - - {screen.screenName} - - - {screen.description &&

{screen.description}

} -
- - {/* 실제 화면 렌더링 영역 */} -
- {layout.components - .filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함) - .map((component) => { - // 그룹 컴포넌트인 경우 특별 처리 - if (component.type === "group") { - const groupChildren = layout.components.filter((child) => child.parentId === component.id); - return ( -
- {/* 그룹 제목 */} - {(component as any).title && ( -
{(component as any).title}
- )} - - {/* 그룹 내 자식 컴포넌트들 렌더링 */} - {groupChildren.map((child) => ( -
- { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - /> -
- ))} -
- ); - } - - // 일반 컴포넌트 렌더링 - return ( -
( +
+ { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); }} - > - { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - /> -
- ); - })} + /> +
+ ))} +
+ ); + } + + // 일반 컴포넌트 렌더링 + return ( +
+ { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + />
-
-
-
- ) : ( -
-
-
- 📄 -
-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+ ); + })} +
+ ) : ( + // 빈 화면일 때도 깔끔하게 표시 +
+
+
+ 📄
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

- )} -
+
+ )}
); } diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx new file mode 100644 index 00000000..d540a783 --- /dev/null +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -0,0 +1,192 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2, Copy } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; +import { toast } from "sonner"; + +interface CopyScreenModalProps { + isOpen: boolean; + onClose: () => void; + sourceScreen: ScreenDefinition | null; + onCopySuccess: () => void; +} + +export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopySuccess }: CopyScreenModalProps) { + const [screenName, setScreenName] = useState(""); + const [screenCode, setScreenCode] = useState(""); + const [description, setDescription] = useState(""); + + const [isCopying, setIsCopying] = useState(false); + + // 모달이 열릴 때 초기값 설정 + useEffect(() => { + if (isOpen && sourceScreen) { + setScreenName(`${sourceScreen.screenName} (복사본)`); + setDescription(sourceScreen.description || ""); + // 화면 코드 자동 생성 + generateNewScreenCode(); + } + }, [isOpen, sourceScreen]); + + // 새로운 화면 코드 자동 생성 + const generateNewScreenCode = async () => { + if (!sourceScreen?.companyCode) return; + + try { + const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode); + setScreenCode(newCode); + } catch (error) { + console.error("화면 코드 생성 실패:", error); + toast.error("화면 코드 생성에 실패했습니다."); + } + }; + + // 화면 복사 실행 + const handleCopy = async () => { + if (!sourceScreen) return; + + // 입력값 검증 + if (!screenName.trim()) { + toast.error("화면명을 입력해주세요."); + return; + } + + if (!screenCode.trim()) { + toast.error("화면 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요."); + return; + } + + try { + setIsCopying(true); + + // 화면 복사 API 호출 + await screenApi.copyScreen(sourceScreen.screenId, { + screenName: screenName.trim(), + screenCode: screenCode.trim(), + description: description.trim(), + }); + + toast.success("화면이 성공적으로 복사되었습니다."); + onCopySuccess(); + handleClose(); + } catch (error: any) { + console.error("화면 복사 실패:", error); + const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다."; + toast.error(errorMessage); + } finally { + setIsCopying(false); + } + }; + + // 모달 닫기 + const handleClose = () => { + setScreenName(""); + setScreenCode(""); + setDescription(""); + onClose(); + }; + + return ( + + + + + + 화면 복사 + + + {sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다. + + + +
+ {/* 원본 화면 정보 */} +
+

원본 화면 정보

+
+
+ 화면명: {sourceScreen?.screenName} +
+
+ 화면코드: {sourceScreen?.screenCode} +
+
+ 회사코드: {sourceScreen?.companyCode} +
+
+
+ + {/* 새 화면 정보 입력 */} +
+
+ + setScreenName(e.target.value)} + placeholder="복사될 화면의 이름을 입력하세요" + className="mt-1" + /> +
+ +
+ + +
+ +
+ +