From 573a300a4a6aebb8d2ab9b2a069bc4c11587d9d6 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 5 Nov 2025 15:23:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../tableCategoryValueController.ts | 246 ++ .../src/routes/tableCategoryValueRoutes.ts | 50 + backend-node/src/services/dataService.ts | 257 +- .../src/services/tableCategoryValueService.ts | 497 ++++ backend-node/src/types/tableCategoryValue.ts | 48 + .../screen/InteractiveScreenViewerDynamic.tsx | 2 +- .../components/screen/RealtimePreview.tsx | 2 +- .../screen/RealtimePreviewDynamic.tsx | 3 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 7 +- .../screen/widgets/CategoryWidget.tsx | 81 + .../screen/widgets/types/ButtonWidget.tsx | 2 +- .../table-category/CategoryColumnList.tsx | 187 ++ .../table-category/CategoryValueAddDialog.tsx | 170 ++ .../CategoryValueEditDialog.tsx | 175 ++ .../table-category/CategoryValueManager.tsx | 378 +++ frontend/constants/tableManagement.ts | 7 + frontend/lib/api/tableCategoryValue.ts | 128 + .../lib/registry/DynamicComponentRenderer.tsx | 6 + .../registry/components/category-manager.tsx | 69 + .../CategoryManagerConfigPanel.tsx | 117 + .../CategoryManagerRenderer.tsx | 76 + frontend/lib/registry/components/index.ts | 1 + frontend/types/input-types.ts | 20 +- frontend/types/tableCategoryValue.ts | 48 + frontend/types/unified-core.ts | 3 +- 동적_테이블_접근_시스템_개선_완료.md | 377 +++ 카테고리_관리_컴포넌트_구현_계획서.md | 2320 +++++++++++++++++ 카테고리_시스템_구현_계획서.md | 1524 +++++++++++ 카테고리_시스템_재구현_계획서.md | 666 +++++ 카테고리_시스템_재구현_완료_보고서.md | 629 +++++ 카테고리_시스템_최종_완료_보고서.md | 483 ++++ 카테고리_컴포넌트_DB_호환성_분석.md | 361 +++ 카테고리_컴포넌트_구현_완료.md | 471 ++++ 카테고리_타입_구현_완료.md | 295 +++ 35 files changed, 9577 insertions(+), 131 deletions(-) create mode 100644 backend-node/src/controllers/tableCategoryValueController.ts create mode 100644 backend-node/src/routes/tableCategoryValueRoutes.ts create mode 100644 backend-node/src/services/tableCategoryValueService.ts create mode 100644 backend-node/src/types/tableCategoryValue.ts create mode 100644 frontend/components/screen/widgets/CategoryWidget.tsx create mode 100644 frontend/components/table-category/CategoryColumnList.tsx create mode 100644 frontend/components/table-category/CategoryValueAddDialog.tsx create mode 100644 frontend/components/table-category/CategoryValueEditDialog.tsx create mode 100644 frontend/components/table-category/CategoryValueManager.tsx create mode 100644 frontend/lib/api/tableCategoryValue.ts create mode 100644 frontend/lib/registry/components/category-manager.tsx create mode 100644 frontend/lib/registry/components/category-manager/CategoryManagerConfigPanel.tsx create mode 100644 frontend/lib/registry/components/category-manager/CategoryManagerRenderer.tsx create mode 100644 frontend/types/tableCategoryValue.ts create mode 100644 동적_테이블_접근_시스템_개선_완료.md create mode 100644 카테고리_관리_컴포넌트_구현_계획서.md create mode 100644 카테고리_시스템_구현_계획서.md create mode 100644 카테고리_시스템_재구현_계획서.md create mode 100644 카테고리_시스템_재구현_완료_보고서.md create mode 100644 카테고리_시스템_최종_완료_보고서.md create mode 100644 카테고리_컴포넌트_DB_호환성_분석.md create mode 100644 카테고리_컴포넌트_구현_완료.md create mode 100644 카테고리_타입_구현_완료.md diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 131b9e1a..c5af1bfe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -66,6 +66,7 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 +import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -226,6 +227,7 @@ app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 +app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts new file mode 100644 index 00000000..75837300 --- /dev/null +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -0,0 +1,246 @@ +import { Request, Response } from "express"; +import tableCategoryValueService from "../services/tableCategoryValueService"; +import { logger } from "../utils/logger"; + +/** + * 테이블의 카테고리 컬럼 목록 조회 + */ +export const getCategoryColumns = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName } = req.params; + + const columns = await tableCategoryValueService.getCategoryColumns( + tableName, + companyCode + ); + + return res.json({ + success: true, + data: columns, + }); + } catch (error: any) { + logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 컬럼 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ +export const getCategoryValues = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + const menuId = parseInt(req.query.menuId as string, 10); + const includeInactive = req.query.includeInactive === "true"; + + if (!menuId || isNaN(menuId)) { + return res.status(400).json({ + success: false, + message: "menuId 파라미터가 필요합니다", + }); + } + + const values = await tableCategoryValueService.getCategoryValues( + tableName, + columnName, + menuId, + companyCode, + includeInactive + ); + + return res.json({ + success: true, + data: values, + }); + } catch (error: any) { + logger.error(`카테고리 값 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 추가 + */ +export const addCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const value = req.body; + + const newValue = await tableCategoryValueService.addCategoryValue( + value, + companyCode, + userId + ); + + return res.status(201).json({ + success: true, + data: newValue, + }); + } catch (error: any) { + logger.error(`카테고리 값 추가 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "카테고리 값 추가 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 수정 + */ +export const updateCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const valueId = parseInt(req.params.valueId); + const updates = req.body; + + if (isNaN(valueId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 값 ID입니다", + }); + } + + const updatedValue = await tableCategoryValueService.updateCategoryValue( + valueId, + updates, + companyCode, + userId + ); + + return res.json({ + success: true, + data: updatedValue, + }); + } catch (error: any) { + logger.error(`카테고리 값 수정 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 수정 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 삭제 + */ +export const deleteCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const valueId = parseInt(req.params.valueId); + + if (isNaN(valueId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 값 ID입니다", + }); + } + + await tableCategoryValueService.deleteCategoryValue( + valueId, + companyCode, + userId + ); + + return res.json({ + success: true, + message: "카테고리 값이 삭제되었습니다", + }); + } catch (error: any) { + logger.error(`카테고리 값 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 일괄 삭제 + */ +export const bulkDeleteCategoryValues = async ( + req: Request, + res: Response +) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { valueIds } = req.body; + + if (!Array.isArray(valueIds) || valueIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 값 ID 목록이 필요합니다", + }); + } + + await tableCategoryValueService.bulkDeleteCategoryValues( + valueIds, + companyCode, + userId + ); + + return res.json({ + success: true, + message: `${valueIds.length}개의 카테고리 값이 삭제되었습니다`, + }); + } catch (error: any) { + logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 일괄 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 순서 변경 + */ +export const reorderCategoryValues = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { orderedValueIds } = req.body; + + if (!Array.isArray(orderedValueIds) || orderedValueIds.length === 0) { + return res.status(400).json({ + success: false, + message: "순서 정보가 필요합니다", + }); + } + + await tableCategoryValueService.reorderCategoryValues( + orderedValueIds, + companyCode + ); + + return res.json({ + success: true, + message: "카테고리 값 순서가 변경되었습니다", + }); + } catch (error: any) { + logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 순서 변경 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts new file mode 100644 index 00000000..cc2ba05f --- /dev/null +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -0,0 +1,50 @@ +import { Router } from "express"; +import * as tableCategoryValueController from "../controllers/tableCategoryValueController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 테이블의 카테고리 컬럼 목록 조회 +router.get( + "/:tableName/columns", + tableCategoryValueController.getCategoryColumns +); + +// 카테고리 값 목록 조회 +router.get( + "/:tableName/:columnName/values", + tableCategoryValueController.getCategoryValues +); + +// 카테고리 값 추가 +router.post("/values", tableCategoryValueController.addCategoryValue); + +// 카테고리 값 수정 +router.put( + "/values/:valueId", + tableCategoryValueController.updateCategoryValue +); + +// 카테고리 값 삭제 +router.delete( + "/values/:valueId", + tableCategoryValueController.deleteCategoryValue +); + +// 카테고리 값 일괄 삭제 +router.post( + "/values/bulk-delete", + tableCategoryValueController.bulkDeleteCategoryValues +); + +// 카테고리 값 순서 변경 +router.post( + "/values/reorder", + tableCategoryValueController.reorderCategoryValues +); + +export default router; + diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 3de082d7..462ebb4d 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1,3 +1,18 @@ +/** + * 동적 데이터 서비스 + * + * 주요 특징: + * 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능 + * 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지 + * 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용 + * 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증 + * + * 보안: + * - 테이블명은 영문, 숫자, 언더스코어만 허용 + * - 시스템 테이블(pg_*, information_schema 등) 접근 금지 + * - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리 + * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 + */ import { query, queryOne } from "../database/db"; interface GetTableDataParams { @@ -17,65 +32,72 @@ interface ServiceResponse { } /** - * 안전한 테이블명 목록 (화이트리스트) - * SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능 + * 접근 금지 테이블 목록 (블랙리스트) + * 시스템 중요 테이블 및 보안상 접근 금지할 테이블 */ -const ALLOWED_TABLES = [ - "company_mng", - "user_info", - "dept_info", - "code_info", - "code_category", - "menu_info", - "approval", - "approval_kind", - "board", - "comm_code", - "product_mng", - "part_mng", - "material_mng", - "order_mng_master", - "inventory_mng", - "contract_mgmt", - "project_mgmt", - "screen_definitions", - "screen_layouts", - "layout_standards", - "component_standards", - "web_type_standards", - "button_action_standards", - "template_standards", - "grid_standards", - "style_templates", - "multi_lang_key_master", - "multi_lang_text", - "language_master", - "table_labels", - "column_labels", - "dynamic_form_data", - "work_history", // 작업 이력 테이블 - "delivery_status", // 배송 현황 테이블 +const BLOCKED_TABLES = [ + "pg_catalog", + "pg_statistic", + "pg_database", + "pg_user", + "information_schema", + "session_tokens", // 세션 토큰 테이블 + "password_history", // 패스워드 이력 ]; /** - * 회사별 필터링이 필요한 테이블 목록 + * 테이블 이름 검증 정규식 + * SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용 */ -const COMPANY_FILTERED_TABLES = [ - "company_mng", - "user_info", - "dept_info", - "approval", - "board", - "product_mng", - "part_mng", - "material_mng", - "order_mng_master", - "inventory_mng", - "contract_mgmt", - "project_mgmt", -]; +const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { + /** + * 테이블 접근 검증 (공통 메서드) + */ + private async validateTableAccess( + tableName: string + ): Promise<{ valid: boolean; error?: ServiceResponse }> { + // 1. 테이블명 형식 검증 (SQL 인젝션 방지) + if (!TABLE_NAME_REGEX.test(tableName)) { + return { + valid: false, + error: { + success: false, + message: `유효하지 않은 테이블명입니다: ${tableName}`, + error: "INVALID_TABLE_NAME", + }, + }; + } + + // 2. 블랙리스트 검증 + if (BLOCKED_TABLES.includes(tableName)) { + return { + valid: false, + error: { + success: false, + message: `접근이 금지된 테이블입니다: ${tableName}`, + error: "TABLE_ACCESS_DENIED", + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + return { + valid: false, + error: { + success: false, + message: `테이블을 찾을 수 없습니다: ${tableName}`, + error: "TABLE_NOT_FOUND", + }, + }; + } + + return { valid: true }; + } + /** * 테이블 데이터 조회 */ @@ -92,23 +114,10 @@ class DataService { } = params; try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; - } - - // 테이블 존재 여부 확인 - const tableExists = await this.checkTableExists(tableName); - if (!tableExists) { - return { - success: false, - message: `테이블을 찾을 수 없습니다: ${tableName}`, - error: "TABLE_NOT_FOUND", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // 동적 SQL 쿼리 생성 @@ -119,13 +128,14 @@ class DataService { // WHERE 조건 생성 const whereConditions: string[] = []; - // 회사별 필터링 추가 - if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) { - // 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용 - if (userCompany !== "*") { + // 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우) + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode) { whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(userCompany); paramIndex++; + console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`); } } @@ -213,13 +223,10 @@ class DataService { */ async getTableColumns(tableName: string): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } const columns = await this.getTableColumnsSimple(tableName); @@ -276,6 +283,31 @@ class DataService { } } + /** + * 특정 컬럼 존재 여부 확인 + */ + private async checkColumnExists( + tableName: string, + columnName: string + ): Promise { + try { + const result = await query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + )`, + [tableName, columnName] + ); + + return result[0]?.exists || false; + } catch (error) { + console.error("컬럼 존재 확인 오류:", error); + return false; + } + } + /** * 테이블 컬럼 정보 조회 (간단 버전) */ @@ -324,13 +356,10 @@ class DataService { id: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 @@ -383,21 +412,16 @@ class DataService { leftValue?: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(leftTable)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`, - error: "TABLE_NOT_ALLOWED", - }; + // 왼쪽 테이블 접근 검증 + const leftValidation = await this.validateTableAccess(leftTable); + if (!leftValidation.valid) { + return leftValidation.error!; } - if (!ALLOWED_TABLES.includes(rightTable)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`, - error: "TABLE_NOT_ALLOWED", - }; + // 오른쪽 테이블 접근 검증 + const rightValidation = await this.validateTableAccess(rightTable); + if (!rightValidation.valid) { + return rightValidation.error!; } let queryText = ` @@ -440,13 +464,10 @@ class DataService { data: Record ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } const columns = Object.keys(data); @@ -485,13 +506,10 @@ class DataService { data: Record ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 @@ -554,13 +572,10 @@ class DataService { id: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts new file mode 100644 index 00000000..a459e24b --- /dev/null +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -0,0 +1,497 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { + TableCategoryValue, + CategoryColumn, +} from "../types/tableCategoryValue"; + +class TableCategoryValueService { + /** + * 메뉴의 형제 메뉴 ID 목록 조회 + * (같은 부모를 가진 메뉴들) + */ + async getSiblingMenuIds(menuId: number): Promise { + try { + const pool = getPool(); + + // 1. 현재 메뉴의 부모 ID 조회 (menu_info는 objid와 parent_obj_id 사용) + const parentQuery = ` + SELECT parent_obj_id FROM menu_info WHERE objid = $1 + `; + const parentResult = await pool.query(parentQuery, [menuId]); + + if (parentResult.rows.length === 0) { + logger.warn(`메뉴 ID ${menuId}를 찾을 수 없습니다`); + return [menuId]; + } + + const parentId = parentResult.rows[0].parent_obj_id; + + // 최상위 메뉴인 경우 (parent_obj_id가 null 또는 0) + if (!parentId || parentId === 0) { + logger.info(`메뉴 ${menuId}는 최상위 메뉴입니다`); + return [menuId]; + } + + // 2. 같은 부모를 가진 형제 메뉴들 조회 + const siblingsQuery = ` + SELECT objid FROM menu_info WHERE parent_obj_id = $1 + `; + const siblingsResult = await pool.query(siblingsQuery, [parentId]); + + const siblingIds = siblingsResult.rows.map((row) => Number(row.objid)); + + logger.info(`메뉴 ${menuId}의 형제 메뉴 ${siblingIds.length}개 조회`, { + menuId, + parentId, + siblings: siblingIds, + }); + + return siblingIds; + } catch (error: any) { + logger.error(`형제 메뉴 조회 실패: ${error.message}`); + // 에러 시 현재 메뉴만 반환 + return [menuId]; + } + } + /** + * 테이블의 카테고리 타입 컬럼 목록 조회 + */ + async getCategoryColumns( + tableName: string, + companyCode: string + ): Promise { + try { + logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode }); + + const pool = getPool(); + const query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COUNT(cv.value_id) AS "valueCount" + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + AND (cv.company_code = $2 OR cv.company_code = '*') + WHERE tc.table_name = $1 + AND tc.input_type = 'category' + GROUP BY tc.table_name, tc.column_name, tc.display_order + ORDER BY tc.display_order, tc.column_name + `; + + const result = await pool.query(query, [tableName, companyCode]); + + logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, { + tableName, + companyCode, + }); + + return result.rows; + } catch (error: any) { + logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ + async getCategoryValues( + tableName: string, + columnName: string, + menuId: number, + companyCode: string, + includeInactive: boolean = false + ): Promise { + try { + logger.info("카테고리 값 목록 조회", { + tableName, + columnName, + menuId, + companyCode, + includeInactive, + }); + + // 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함 + const siblingMenuIds = await this.getSiblingMenuIds(menuId); + + const pool = getPool(); + let query = ` + SELECT + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + menu_objid AS "menuId", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND menu_objid = ANY($3) + AND (company_code = $4 OR company_code = '*') + `; + + const params: any[] = [tableName, columnName, siblingMenuIds, companyCode]; + + if (!includeInactive) { + query += ` AND is_active = true`; + } + + query += ` ORDER BY value_order, value_label`; + + const result = await pool.query(query, params); + + // 계층 구조로 변환 + const values = this.buildHierarchy(result.rows); + + logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, { + tableName, + columnName, + menuId, + siblingMenuIds, + }); + + return values; + } catch (error: any) { + logger.error(`카테고리 값 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 추가 + */ + async addCategoryValue( + value: TableCategoryValue, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + // 중복 코드 체크 + const duplicateQuery = ` + SELECT value_id + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_code = $3 + AND (company_code = $4 OR company_code = '*') + `; + + const duplicateResult = await pool.query(duplicateQuery, [ + value.tableName, + value.columnName, + value.valueCode, + companyCode, + ]); + + if (duplicateResult.rows.length > 0) { + throw new Error("이미 존재하는 코드입니다"); + } + + const insertQuery = ` + INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, menu_objid, company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + menu_objid AS "menuId", + company_code AS "companyCode", + created_at AS "createdAt", + created_by AS "createdBy" + `; + + const result = await pool.query(insertQuery, [ + value.tableName, + value.columnName, + value.valueCode, + value.valueLabel, + value.valueOrder || 0, + value.parentValueId || null, + value.depth || 1, + value.description || null, + value.color || null, + value.icon || null, + value.isActive !== false, + value.isDefault || false, + value.menuId, // menuId 추가 + companyCode, + userId, + ]); + + logger.info("카테고리 값 추가 완료", { + valueId: result.rows[0].valueId, + tableName: value.tableName, + columnName: value.columnName, + }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`카테고리 값 추가 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 수정 + */ + async updateCategoryValue( + valueId: number, + updates: Partial, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (updates.valueLabel !== undefined) { + setClauses.push(`value_label = $${paramIndex++}`); + values.push(updates.valueLabel); + } + + if (updates.valueOrder !== undefined) { + setClauses.push(`value_order = $${paramIndex++}`); + values.push(updates.valueOrder); + } + + if (updates.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + values.push(updates.description); + } + + if (updates.color !== undefined) { + setClauses.push(`color = $${paramIndex++}`); + values.push(updates.color); + } + + if (updates.icon !== undefined) { + setClauses.push(`icon = $${paramIndex++}`); + values.push(updates.icon); + } + + if (updates.isActive !== undefined) { + setClauses.push(`is_active = $${paramIndex++}`); + values.push(updates.isActive); + } + + if (updates.isDefault !== undefined) { + setClauses.push(`is_default = $${paramIndex++}`); + values.push(updates.isDefault); + } + + setClauses.push(`updated_at = NOW()`); + setClauses.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(valueId, companyCode); + + const updateQuery = ` + UPDATE table_column_category_values + SET ${setClauses.join(", ")} + WHERE value_id = $${paramIndex++} + AND (company_code = $${paramIndex++} OR company_code = '*') + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + menu_objid AS "menuId", + updated_at AS "updatedAt", + updated_by AS "updatedBy" + `; + + const result = await pool.query(updateQuery, values); + + if (result.rowCount === 0) { + throw new Error("카테고리 값을 찾을 수 없습니다"); + } + + logger.info("카테고리 값 수정 완료", { valueId, companyCode }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`카테고리 값 수정 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 삭제 (비활성화) + */ + async deleteCategoryValue( + valueId: number, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + // 하위 값 체크 + const checkQuery = ` + SELECT COUNT(*) as count + FROM table_column_category_values + WHERE parent_value_id = $1 + AND (company_code = $2 OR company_code = '*') + AND is_active = true + `; + + const checkResult = await pool.query(checkQuery, [valueId, companyCode]); + + if (parseInt(checkResult.rows[0].count) > 0) { + throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); + } + + // 비활성화 + const deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = $1 + AND (company_code = $2 OR company_code = '*') + `; + + await pool.query(deleteQuery, [valueId, companyCode, userId]); + + logger.info("카테고리 값 삭제(비활성화) 완료", { + valueId, + companyCode, + }); + } catch (error: any) { + logger.error(`카테고리 값 삭제 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 일괄 삭제 + */ + async bulkDeleteCategoryValues( + valueIds: number[], + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + const deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = ANY($1::int[]) + AND (company_code = $2 OR company_code = '*') + `; + + await pool.query(deleteQuery, [valueIds, companyCode, userId]); + + logger.info("카테고리 값 일괄 삭제 완료", { + count: valueIds.length, + companyCode, + }); + } catch (error: any) { + logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 순서 변경 + */ + async reorderCategoryValues( + orderedValueIds: number[], + companyCode: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (let i = 0; i < orderedValueIds.length; i++) { + const updateQuery = ` + UPDATE table_column_category_values + SET value_order = $1, updated_at = NOW() + WHERE value_id = $2 + AND (company_code = $3 OR company_code = '*') + `; + + await client.query(updateQuery, [ + i + 1, + orderedValueIds[i], + companyCode, + ]); + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 순서 변경 완료", { + count: orderedValueIds.length, + companyCode, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); + throw error; + } finally { + client.release(); + } + } + + /** + * 계층 구조 변환 헬퍼 + */ + private buildHierarchy( + values: TableCategoryValue[], + parentId: number | null = null + ): TableCategoryValue[] { + return values + .filter((v) => v.parentValueId === parentId) + .map((v) => ({ + ...v, + children: this.buildHierarchy(values, v.valueId!), + })); + } +} + +export default new TableCategoryValueService(); + diff --git a/backend-node/src/types/tableCategoryValue.ts b/backend-node/src/types/tableCategoryValue.ts new file mode 100644 index 00000000..ee1c4c2f --- /dev/null +++ b/backend-node/src/types/tableCategoryValue.ts @@ -0,0 +1,48 @@ +/** + * 테이블 컬럼별 카테고리 값 타입 정의 + */ + +export interface TableCategoryValue { + valueId?: number; + tableName: string; + columnName: string; + + // 값 정보 + valueCode: string; + valueLabel: string; + valueOrder?: number; + + // 계층 구조 + parentValueId?: number; + depth?: number; + + // 추가 정보 + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; + + // 하위 항목 (조회 시) + children?: TableCategoryValue[]; + + // 메뉴 스코프 + menuId: number; + + // 멀티테넌시 + companyCode?: string; + + // 메타 + createdAt?: string; + updatedAt?: string; + createdBy?: string; + updatedBy?: string; +} + +export interface CategoryColumn { + tableName: string; + columnName: string; + columnLabel: string; + valueCount?: number; // 값 개수 +} + diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index c3e09f2e..882dfd70 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -615,7 +615,7 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); // 높이 결정 로직 - let finalHeight = size?.height || 40; + let finalHeight = size?.height || 10; if (isFlowWidget && actualHeight) { finalHeight = actualHeight; } diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 1f11182f..af733601 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -271,7 +271,8 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${Math.max(size?.height || 200, 200)}px`; } - return `${size?.height || 40}px`; + // size.height가 있으면 그대로 사용, 없으면 최소 10px + return `${size?.height || 10}px`; }; const baseStyle = { diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index f2e50db8..cc06b555 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -364,14 +364,13 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.size?.height || 0} onChange={(e) => { const value = parseInt(e.target.value) || 0; - const roundedValue = Math.max(10, Math.round(value / 10) * 10); - handleUpdate("size.height", roundedValue); + // 최소값 제한 없이, 1px 단위로 조절 가능 + handleUpdate("size.height", Math.max(1, value)); }} - step={10} + step={1} placeholder="10" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> diff --git a/frontend/components/screen/widgets/CategoryWidget.tsx b/frontend/components/screen/widgets/CategoryWidget.tsx new file mode 100644 index 00000000..685340b5 --- /dev/null +++ b/frontend/components/screen/widgets/CategoryWidget.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useState } from "react"; +import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; +import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; + +interface CategoryWidgetProps { + widgetId: string; + menuId?: number; // 현재 화면의 menuId (선택사항) + tableName: string; // 현재 화면의 테이블 + selectedScreen?: any; // 화면 정보 전체 (menuId 추출용) +} + +/** + * 카테고리 관리 위젯 (좌우 분할) + * - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록 + * - 우측: 선택된 컬럼의 카테고리 값 관리 + */ +export function CategoryWidget({ + widgetId, + menuId: propMenuId, + tableName, + selectedScreen, +}: CategoryWidgetProps) { + const [selectedColumn, setSelectedColumn] = useState<{ + columnName: string; + columnLabel: string; + } | null>(null); + + // menuId 추출: props > selectedScreen > 기본값(1) + const menuId = + propMenuId || + selectedScreen?.menuId || + selectedScreen?.menu_id || + 1; // 기본값 + + // menuId가 없으면 경고 메시지 표시 + if (!menuId || menuId === 1) { + console.warn("⚠️ CategoryWidget: menuId가 제공되지 않아 기본값(1)을 사용합니다", { + propMenuId, + selectedScreen, + }); + } + + return ( +
+ {/* 좌측: 카테고리 컬럼 리스트 (30%) */} +
+ + setSelectedColumn({ columnName, columnLabel }) + } + /> +
+ + {/* 우측: 카테고리 값 관리 (70%) */} +
+ {selectedColumn ? ( + + ) : ( +
+
+

+ 좌측에서 관리할 카테고리 컬럼을 선택하세요 +

+
+
+ )} +
+
+ ); +} + diff --git a/frontend/components/screen/widgets/types/ButtonWidget.tsx b/frontend/components/screen/widgets/types/ButtonWidget.tsx index 6bc9e1ff..808cf5d0 100644 --- a/frontend/components/screen/widgets/types/ButtonWidget.tsx +++ b/frontend/components/screen/widgets/types/ButtonWidget.tsx @@ -30,7 +30,7 @@ export const ButtonWidget: React.FC = ({ type="button" onClick={handleClick} disabled={disabled || readonly} - className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} + className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} style={{ ...style, width: "100%", diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx new file mode 100644 index 00000000..3cc8cb11 --- /dev/null +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { apiClient } from "@/lib/api/client"; +import { FolderTree, Loader2 } from "lucide-react"; + +interface CategoryColumn { + columnName: string; + columnLabel: string; + inputType: string; +} + +interface CategoryColumnListProps { + tableName: string; + menuId: number; + selectedColumn: string | null; + onColumnSelect: (columnName: string, columnLabel: string) => void; +} + +/** + * 카테고리 컬럼 목록 (좌측 패널) + * - 현재 테이블에서 input_type='category'인 컬럼들을 표시 + */ +export function CategoryColumnList({ + tableName, + menuId, + selectedColumn, + onColumnSelect, +}: CategoryColumnListProps) { + const [columns, setColumns] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadCategoryColumns(); + }, [tableName, menuId]); + + const loadCategoryColumns = async () => { + setIsLoading(true); + try { + // table_type_columns에서 input_type = 'category'인 컬럼 조회 + const response = await apiClient.get( + `/table-management/tables/${tableName}/columns` + ); + + console.log("🔍 테이블 컬럼 API 응답:", { + tableName, + response: response.data, + type: typeof response.data, + isArray: Array.isArray(response.data), + }); + + // API 응답 구조 파싱 (여러 가능성 대응) + let allColumns: any[] = []; + + if (Array.isArray(response.data)) { + // response.data가 직접 배열인 경우 + allColumns = response.data; + } else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) { + // response.data.data.columns가 배열인 경우 (table-management API) + allColumns = response.data.data.columns; + } else if (response.data.data && Array.isArray(response.data.data)) { + // response.data.data가 배열인 경우 + allColumns = response.data.data; + } else if (response.data.columns && Array.isArray(response.data.columns)) { + // response.data.columns가 배열인 경우 + allColumns = response.data.columns; + } else { + console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data); + allColumns = []; + } + + console.log("🔍 파싱된 컬럼 목록:", { + totalColumns: allColumns.length, + sample: allColumns.slice(0, 3), + }); + + // category 타입만 필터링 + const categoryColumns = allColumns.filter( + (col: any) => col.inputType === "category" || col.input_type === "category" + ); + + console.log("✅ 카테고리 컬럼:", { + count: categoryColumns.length, + columns: categoryColumns.map((c: any) => ({ + name: c.columnName || c.column_name, + type: c.inputType || c.input_type, + })), + }); + + setColumns( + categoryColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.displayName || col.columnName || col.column_name, + inputType: col.inputType || col.input_type, + })) + ); + + // 첫 번째 컬럼 자동 선택 + if (categoryColumns.length > 0 && !selectedColumn) { + const firstCol = categoryColumns[0]; + const colName = firstCol.columnName || firstCol.column_name; + const colLabel = firstCol.columnLabel || firstCol.column_label || firstCol.displayName || colName; + onColumnSelect(colName, colLabel); + } + } catch (error) { + console.error("❌ 카테고리 컬럼 조회 실패:", error); + setColumns([]); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (columns.length === 0) { + return ( +
+

카테고리 컬럼

+
+ +

+ 카테고리 타입 컬럼이 없습니다 +

+

+ 테이블 타입 관리에서 컬럼의 입력 타입을 '카테고리'로 + 설정하세요 +

+
+
+ ); + } + + return ( +
+
+

카테고리 컬럼

+

+ 관리할 카테고리 컬럼을 선택하세요 +

+
+ +
+ {columns.map((column) => ( +
+ onColumnSelect( + column.columnName, + column.columnLabel || column.columnName + ) + } + className={`cursor-pointer rounded-lg border p-4 transition-all ${ + selectedColumn === column.columnName + ? "border-primary bg-primary/10 shadow-sm" + : "hover:bg-muted/50" + }`} + > +
+ +
+

+ {column.columnLabel || column.columnName} +

+

+ {column.columnName} +

+
+
+
+ ))} +
+
+ ); +} + diff --git a/frontend/components/table-category/CategoryValueAddDialog.tsx b/frontend/components/table-category/CategoryValueAddDialog.tsx new file mode 100644 index 00000000..b511ae7a --- /dev/null +++ b/frontend/components/table-category/CategoryValueAddDialog.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} 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 { Checkbox } from "@/components/ui/checkbox"; +import { TableCategoryValue } from "@/types/tableCategoryValue"; + +interface CategoryValueAddDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (value: TableCategoryValue) => void; + columnLabel: string; +} + +export const CategoryValueAddDialog: React.FC< + CategoryValueAddDialogProps +> = ({ open, onOpenChange, onAdd, columnLabel }) => { + const [valueCode, setValueCode] = useState(""); + const [valueLabel, setValueLabel] = useState(""); + const [description, setDescription] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [isDefault, setIsDefault] = useState(false); + + const handleSubmit = () => { + if (!valueCode || !valueLabel) { + return; + } + + onAdd({ + tableName: "", + columnName: "", + valueCode: valueCode.toUpperCase(), + valueLabel, + description, + color, + isDefault, + }); + + // 초기화 + setValueCode(""); + setValueLabel(""); + setDescription(""); + setColor("#3b82f6"); + setIsDefault(false); + }; + + return ( + + + + + 새 카테고리 값 추가 + + + {columnLabel}에 새로운 값을 추가합니다 + + + +
+
+ + setValueCode(e.target.value.toUpperCase())} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 영문 대문자와 언더스코어만 사용 (DB 저장값) +

+
+ +
+ + setValueLabel(e.target.value)} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 사용자에게 표시될 이름 +

+
+ +
+ +