From ae4e21e1aca3064f550e8095e571e493c430cf6e Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 21 Jan 2026 15:03:27 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 트리 컨트롤러와 서비스 추가: 트리 구조를 지원하는 카테고리 값 관리 기능을 구현하였습니다. - 카테고리 트리 API 클라이언트 추가: CRUD 작업을 위한 API 클라이언트를 구현하였습니다. - 카테고리 값 관리 컴포넌트 및 설정 패널 추가: 사용자 인터페이스에서 카테고리 값을 관리할 수 있도록 트리 구조 기반의 컴포넌트를 추가하였습니다. - 관련 라우트 및 레지스트리 업데이트: 카테고리 트리 관련 라우트를 추가하고, 컴포넌트 레지스트리에 등록하였습니다. 이로 인해 카테고리 관리의 효율성이 향상되었습니다. --- backend-node/src/app.ts | 2 + .../src/controllers/categoryTreeController.ts | 226 ++++++ .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/routes/categoryTreeRoutes.ts | 8 + .../src/services/categoryTreeService.ts | 513 +++++++++++++ docs/노드플로우_개선사항.md | 1 + docs/다국어_관리_시스템_개선_계획서.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + docs/집계위젯_개발진행상황.md | 1 + .../table-category/CategoryColumnList.tsx | 108 ++- .../CategoryValueManagerTree.tsx | 720 ++++++++++++++++++ frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/useAutoFill.ts | 1 + frontend/lib/api/categoryTree.ts | 191 +++++ frontend/lib/registry/components/index.ts | 1 + .../V2CategoryManagerComponent.tsx | 206 +++++ .../V2CategoryManagerConfigPanel.tsx | 126 +++ .../V2CategoryManagerRenderer.tsx | 13 + .../components/v2-category-manager/index.ts | 36 + .../components/v2-category-manager/types.ts | 54 ++ ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 27 files changed, 2213 insertions(+), 5 deletions(-) create mode 100644 backend-node/src/controllers/categoryTreeController.ts create mode 100644 backend-node/src/routes/categoryTreeRoutes.ts create mode 100644 backend-node/src/services/categoryTreeService.ts create mode 100644 frontend/components/table-category/CategoryValueManagerTree.tsx create mode 100644 frontend/lib/api/categoryTree.ts create mode 100644 frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx create mode 100644 frontend/lib/registry/components/v2-category-manager/V2CategoryManagerConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-category-manager/V2CategoryManagerRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-category-manager/index.ts create mode 100644 frontend/lib/registry/components/v2-category-manager/types.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 76699e7b..daf9e344 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -83,6 +83,7 @@ import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 +import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -262,6 +263,7 @@ app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 +app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts new file mode 100644 index 00000000..8f76bbc7 --- /dev/null +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -0,0 +1,226 @@ +/** + * 카테고리 트리 컨트롤러 (테스트용) + */ + +import { Router, Request, Response } from "express"; +import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService"; +import { logger } from "../utils/logger"; + +const router = Router(); + +// 인증된 사용자 타입 +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + }; +} + +/** + * 카테고리 트리 조회 + * GET /api/category-tree/test/:tableName/:columnName + */ +router.get("/test/:tableName/:columnName", async (req: AuthenticatedRequest, res: Response) => { + try { + const { tableName, columnName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + const tree = await categoryTreeService.getCategoryTree(companyCode, tableName, columnName); + + res.json({ + success: true, + data: tree, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 트리 조회 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +/** + * 카테고리 목록 조회 (플랫 리스트) + * GET /api/category-tree/test/:tableName/:columnName/flat + */ +router.get("/test/:tableName/:columnName/flat", async (req: AuthenticatedRequest, res: Response) => { + try { + const { tableName, columnName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + const list = await categoryTreeService.getCategoryList(companyCode, tableName, columnName); + + res.json({ + success: true, + data: list, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 목록 조회 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +/** + * 카테고리 값 단일 조회 + * GET /api/category-tree/test/value/:valueId + */ +router.get("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => { + try { + const { valueId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + const value = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); + + if (!value) { + return res.status(404).json({ + success: false, + error: "카테고리 값을 찾을 수 없습니다", + }); + } + + res.json({ + success: true, + data: value, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 조회 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +/** + * 카테고리 값 생성 + * POST /api/category-tree/test/value + */ +router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => { + try { + const input: CreateCategoryValueInput = req.body; + const companyCode = req.user?.companyCode || "*"; + const createdBy = req.user?.userId; + + if (!input.tableName || !input.columnName || !input.valueCode || !input.valueLabel) { + return res.status(400).json({ + success: false, + error: "tableName, columnName, valueCode, valueLabel은 필수입니다", + }); + } + + const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy); + + res.json({ + success: true, + data: value, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 생성 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +/** + * 카테고리 값 수정 + * PUT /api/category-tree/test/value/:valueId + */ +router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => { + try { + const { valueId } = req.params; + const input: UpdateCategoryValueInput = req.body; + const companyCode = req.user?.companyCode || "*"; + const updatedBy = req.user?.userId; + + const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy); + + if (!value) { + return res.status(404).json({ + success: false, + error: "카테고리 값을 찾을 수 없습니다", + }); + } + + res.json({ + success: true, + data: value, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 수정 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +/** + * 카테고리 값 삭제 + * DELETE /api/category-tree/test/value/:valueId + */ +router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => { + try { + const { valueId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId)); + + if (!success) { + return res.status(404).json({ + success: false, + error: "카테고리 값을 찾을 수 없습니다", + }); + } + + res.json({ + success: true, + message: "삭제되었습니다", + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 삭제 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +/** + * 테이블의 카테고리 컬럼 목록 조회 + * GET /api/category-tree/test/columns/:tableName + */ +router.get("/test/columns/:tableName", async (req: AuthenticatedRequest, res: Response) => { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + const columns = await categoryTreeService.getCategoryColumns(companyCode, tableName); + + res.json({ + success: true, + data: columns, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 컬럼 목록 조회 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + +export default router; + diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 81eef3e9..0c44431e 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -59,3 +59,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 57d533e4..f6fb3177 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -55,3 +55,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 934034d0..c8aab3ad 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -71,3 +71,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 4a3f5543..fbaf4c37 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -59,3 +59,4 @@ export default router; + diff --git a/backend-node/src/routes/categoryTreeRoutes.ts b/backend-node/src/routes/categoryTreeRoutes.ts new file mode 100644 index 00000000..e7d954b9 --- /dev/null +++ b/backend-node/src/routes/categoryTreeRoutes.ts @@ -0,0 +1,8 @@ +/** + * 카테고리 트리 라우트 (테스트용) + */ + +import categoryTreeController from "../controllers/categoryTreeController"; + +export default categoryTreeController; + diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts new file mode 100644 index 00000000..591d7873 --- /dev/null +++ b/backend-node/src/services/categoryTreeService.ts @@ -0,0 +1,513 @@ +/** + * 카테고리 트리 서비스 (테스트용) + * - 트리 구조 지원 (최대 3단계: 대분류/중분류/소분류) + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// 카테고리 값 타입 +export interface CategoryValue { + valueId: number; + tableName: string; + columnName: string; + valueCode: string; + valueLabel: string; + valueOrder: number; + parentValueId: number | null; + depth: number; + path: string | null; + description: string | null; + color: string | null; + icon: string | null; + isActive: boolean; + isDefault: boolean; + companyCode: string; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + updatedBy: string | null; + children?: CategoryValue[]; +} + +// 카테고리 값 생성 입력 +export interface CreateCategoryValueInput { + tableName: string; + columnName: string; + valueCode: string; + valueLabel: string; + valueOrder?: number; + parentValueId?: number | null; + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; +} + +// 카테고리 값 수정 입력 +export interface UpdateCategoryValueInput { + valueCode?: string; + valueLabel?: string; + valueOrder?: number; + parentValueId?: number | null; + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; +} + +class CategoryTreeService { + /** + * 카테고리 값 목록 조회 (트리 구조로 반환) + */ + async getCategoryTree(companyCode: string, tableName: string, columnName: string): Promise { + const pool = getPool(); + + try { + logger.info("카테고리 트리 조회 시작", { companyCode, tableName, columnName }); + + const 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, + path, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM category_values_test + WHERE (company_code = $1 OR company_code = '*') + AND table_name = $2 + AND column_name = $3 + ORDER BY depth ASC, value_order ASC, value_label ASC + `; + + const result = await pool.query(query, [companyCode, tableName, columnName]); + const flatList = result.rows as CategoryValue[]; + + const tree = this.buildTree(flatList); + + logger.info("카테고리 트리 조회 완료", { + tableName, + columnName, + totalCount: flatList.length, + rootCount: tree.length, + }); + + return tree; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 트리 조회 실패", { error: err.message, tableName, columnName }); + throw error; + } + } + + /** + * 카테고리 값 목록 조회 (플랫 리스트) + */ + async getCategoryList(companyCode: string, tableName: string, columnName: string): Promise { + const pool = getPool(); + + try { + const 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, + path, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM category_values_test + WHERE (company_code = $1 OR company_code = '*') + AND table_name = $2 + AND column_name = $3 + ORDER BY depth ASC, value_order ASC, value_label ASC + `; + + const result = await pool.query(query, [companyCode, tableName, columnName]); + return result.rows as CategoryValue[]; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 목록 조회 실패", { error: err.message }); + throw error; + } + } + + /** + * 카테고리 값 단일 조회 + */ + async getCategoryValue(companyCode: string, valueId: number): Promise { + const pool = getPool(); + + try { + const 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, + path, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM category_values_test + WHERE (company_code = $1 OR company_code = '*') AND value_id = $2 + `; + + const result = await pool.query(query, [companyCode, valueId]); + return result.rows[0] || null; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 조회 실패", { error: err.message, valueId }); + throw error; + } + } + + /** + * 카테고리 값 생성 + */ + async createCategoryValue(companyCode: string, input: CreateCategoryValueInput, createdBy?: string): Promise { + const pool = getPool(); + + try { + // depth 계산 + let depth = 1; + let path = input.valueLabel; + + if (input.parentValueId) { + const parent = await this.getCategoryValue(companyCode, input.parentValueId); + if (parent) { + depth = parent.depth + 1; + path = parent.path ? `${parent.path}/${input.valueLabel}` : input.valueLabel; + + if (depth > 3) { + throw new Error("카테고리는 최대 3단계까지만 가능합니다"); + } + } + } + + const query = ` + INSERT INTO category_values_test ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, path, description, color, icon, + is_active, is_default, company_code, created_by, updated_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $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, + path, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt" + `; + + const params = [ + input.tableName, + input.columnName, + input.valueCode, + input.valueLabel, + input.valueOrder ?? 0, + input.parentValueId ?? null, + depth, + path, + input.description ?? null, + input.color ?? null, + input.icon ?? null, + input.isActive ?? true, + input.isDefault ?? false, + companyCode, + createdBy ?? null, + ]; + + const result = await pool.query(query, params); + + logger.info("카테고리 값 생성 완료", { + valueId: result.rows[0].valueId, + valueLabel: input.valueLabel, + depth, + }); + + return result.rows[0]; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 생성 실패", { error: err.message, input }); + throw error; + } + } + + /** + * 카테고리 값 수정 + */ + async updateCategoryValue( + companyCode: string, + valueId: number, + input: UpdateCategoryValueInput, + updatedBy?: string + ): Promise { + const pool = getPool(); + + try { + const current = await this.getCategoryValue(companyCode, valueId); + if (!current) { + return null; + } + + let newPath = current.path; + let newDepth = current.depth; + + if (input.valueLabel && input.valueLabel !== current.valueLabel) { + if (current.parentValueId) { + const parent = await this.getCategoryValue(companyCode, current.parentValueId); + if (parent && parent.path) { + newPath = `${parent.path}/${input.valueLabel}`; + } else { + newPath = input.valueLabel; + } + } else { + newPath = input.valueLabel; + } + } + + if (input.parentValueId !== undefined && input.parentValueId !== current.parentValueId) { + if (input.parentValueId) { + const newParent = await this.getCategoryValue(companyCode, input.parentValueId); + if (newParent) { + newDepth = newParent.depth + 1; + const label = input.valueLabel ?? current.valueLabel; + newPath = newParent.path ? `${newParent.path}/${label}` : label; + + if (newDepth > 3) { + throw new Error("카테고리는 최대 3단계까지만 가능합니다"); + } + } + } else { + newDepth = 1; + newPath = input.valueLabel ?? current.valueLabel; + } + } + + const query = ` + UPDATE category_values_test + SET + value_code = COALESCE($3, value_code), + value_label = COALESCE($4, value_label), + value_order = COALESCE($5, value_order), + parent_value_id = $6, + depth = $7, + path = $8, + description = COALESCE($9, description), + color = COALESCE($10, color), + icon = COALESCE($11, icon), + is_active = COALESCE($12, is_active), + is_default = COALESCE($13, is_default), + updated_at = NOW(), + updated_by = $14 + WHERE (company_code = $1 OR company_code = '*') AND value_id = $2 + 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, + path, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt" + `; + + const params = [ + companyCode, + valueId, + input.valueCode ?? null, + input.valueLabel ?? null, + input.valueOrder ?? null, + input.parentValueId !== undefined ? input.parentValueId : current.parentValueId, + newDepth, + newPath, + input.description ?? null, + input.color ?? null, + input.icon ?? null, + input.isActive ?? null, + input.isDefault ?? null, + updatedBy ?? null, + ]; + + const result = await pool.query(query, params); + + if (input.valueLabel || input.parentValueId !== undefined) { + await this.updateChildrenPaths(companyCode, valueId, newPath || ""); + } + + logger.info("카테고리 값 수정 완료", { valueId }); + + return result.rows[0] || null; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 수정 실패", { error: err.message, valueId }); + throw error; + } + } + + /** + * 카테고리 값 삭제 (하위 항목도 함께 삭제) + */ + async deleteCategoryValue(companyCode: string, valueId: number): Promise { + const pool = getPool(); + + try { + const query = ` + DELETE FROM category_values_test + WHERE (company_code = $1 OR company_code = '*') AND value_id = $2 + RETURNING value_id + `; + + const result = await pool.query(query, [companyCode, valueId]); + + if (result.rowCount && result.rowCount > 0) { + logger.info("카테고리 값 삭제 완료", { valueId }); + return true; + } + + return false; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 값 삭제 실패", { error: err.message, valueId }); + throw error; + } + } + + /** + * 하위 항목들의 path 업데이트 + */ + private async updateChildrenPaths(companyCode: string, parentValueId: number, parentPath: string): Promise { + const pool = getPool(); + + const query = ` + SELECT value_id, value_label + FROM category_values_test + WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2 + `; + + const result = await pool.query(query, [companyCode, parentValueId]); + + for (const child of result.rows) { + const newPath = `${parentPath}/${child.value_label}`; + + await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [ + newPath, + child.value_id, + ]); + + await this.updateChildrenPaths(companyCode, child.value_id, newPath); + } + } + + /** + * 플랫 리스트를 트리 구조로 변환 + */ + private buildTree(flatList: CategoryValue[]): CategoryValue[] { + const map = new Map(); + const roots: CategoryValue[] = []; + + for (const item of flatList) { + map.set(item.valueId, { ...item, children: [] }); + } + + for (const item of flatList) { + const node = map.get(item.valueId)!; + + if (item.parentValueId && map.has(item.parentValueId)) { + const parent = map.get(item.parentValueId)!; + parent.children = parent.children || []; + parent.children.push(node); + } else { + roots.push(node); + } + } + + return roots; + } + + /** + * 테이블의 카테고리 컬럼 목록 조회 + */ + async getCategoryColumns(companyCode: string, tableName: string): Promise<{ columnName: string; columnLabel: string }[]> { + const pool = getPool(); + + try { + const query = ` + SELECT DISTINCT column_name AS "columnName", column_label AS "columnLabel" + FROM table_type_columns + WHERE table_name = $1 + AND input_type = 'category' + AND (company_code = $2 OR company_code = '*') + ORDER BY column_name + `; + + const result = await pool.query(query, [tableName, companyCode]); + return result.rows; + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 컬럼 목록 조회 실패", { error: err.message, tableName }); + throw error; + } + } +} + +export const categoryTreeService = new CategoryTreeService(); + diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 1c688d74..874bacc3 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -591,3 +591,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/다국어_관리_시스템_개선_계획서.md b/docs/다국어_관리_시스템_개선_계획서.md index 0b4e7135..de5c30da 100644 --- a/docs/다국어_관리_시스템_개선_계획서.md +++ b/docs/다국어_관리_시스템_개선_계획서.md @@ -597,3 +597,4 @@ POST /multilang/keys/123/override + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 699d1b66..8e479890 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -364,3 +364,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index e6a63d83..597cb718 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -350,3 +350,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/docs/집계위젯_개발진행상황.md b/docs/집계위젯_개발진행상황.md index 79e2abd5..ae3eb693 100644 --- a/docs/집계위젯_개발진행상황.md +++ b/docs/집계위젯_개발진행상황.md @@ -209,3 +209,4 @@ console.log("[AggregationWidget] selectableComponents:", filtered); - `frontend/components/screen/ScreenDesigner.tsx` - `layout.components` 전달 + diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index c75676ba..bfdd0f9a 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -32,11 +32,96 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, useEffect(() => { if (menuObjid) { loadCategoryColumnsByMenu(); + } else if (tableName) { + // menuObjid가 없으면 tableName 기반으로 조회 + loadCategoryColumnsByTable(); } else { - console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다"); + console.warn("⚠️ menuObjid와 tableName 모두 없어서 카테고리 컬럼을 로드할 수 없습니다"); setColumns([]); } - }, [menuObjid]); + }, [menuObjid, tableName]); + + // tableName 기반으로 카테고리 컬럼 조회 + const loadCategoryColumnsByTable = async () => { + setIsLoading(true); + try { + console.log("🔍 테이블 기반 카테고리 컬럼 조회 시작", { tableName }); + + // table_type_columns에서 input_type='category'인 컬럼 조회 + const response = await apiClient.get(`/screen-management/tables/${tableName}/columns`); + + console.log("✅ 테이블 컬럼 API 응답:", response.data); + + let allColumns: any[] = []; + if (response.data.success && response.data.data) { + allColumns = response.data.data; + } else if (Array.isArray(response.data)) { + allColumns = response.data; + } + + // category 타입 컬럼만 필터링 + const categoryColumns = allColumns.filter( + (col: any) => col.inputType === "category" || col.input_type === "category" + ); + + console.log("✅ 카테고리 컬럼 필터링 완료:", { + total: allColumns.length, + categoryCount: categoryColumns.length, + }); + + // 값 개수 조회 (테스트 테이블 사용) + const columnsWithCount = await Promise.all( + categoryColumns.map(async (col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.columnLabel || col.column_label || colName; + + let valueCount = 0; + try { + // 테스트 테이블에서 조회 + const treeResponse = await apiClient.get(`/category-tree/test/${tableName}/${colName}`); + if (treeResponse.data.success && treeResponse.data.data) { + valueCount = countTreeNodes(treeResponse.data.data); + } + } catch (error) { + console.error(`항목 개수 조회 실패 (${tableName}.${colName}):`, error); + } + + return { + tableName, + tableLabel: tableName, + columnName: colName, + columnLabel: colLabel, + inputType: "category", + valueCount, + }; + }), + ); + + setColumns(columnsWithCount); + + // 첫 번째 컬럼 자동 선택 + if (columnsWithCount.length > 0 && !selectedColumn) { + const firstCol = columnsWithCount[0]; + onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); + } + } catch (error) { + console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error); + setColumns([]); + } finally { + setIsLoading(false); + } + }; + + // 트리 노드 수 계산 함수 + const countTreeNodes = (nodes: any[]): number => { + let count = nodes.length; + for (const node of nodes) { + if (node.children && Array.isArray(node.children)) { + count += countTreeNodes(node.children); + } + } + return count; + }; const loadCategoryColumnsByMenu = async () => { setIsLoading(true); @@ -99,6 +184,13 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, }), ); + // 결과가 0개면 tableName 기반으로 fallback + if (columnsWithCount.length === 0 && tableName) { + console.log("⚠️ menuObjid 기반 조회 결과 없음, tableName 기반으로 fallback:", tableName); + await loadCategoryColumnsByTable(); + return; + } + setColumns(columnsWithCount); // 첫 번째 컬럼 자동 선택 @@ -108,10 +200,16 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } } catch (error) { console.error("❌ 카테고리 컬럼 조회 실패:", error); - setColumns([]); - } finally { - setIsLoading(false); + // 에러 시에도 tableName 기반으로 fallback + if (tableName) { + console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName); + await loadCategoryColumnsByTable(); + return; + } else { + setColumns([]); + } } + setIsLoading(false); }; if (isLoading) { diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx new file mode 100644 index 00000000..f7c991a8 --- /dev/null +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -0,0 +1,720 @@ +"use client"; + +/** + * 카테고리 값 관리 - 트리 구조 버전 + * - 3단계 트리 구조 지원 (대분류/중분류/소분류) + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { + ChevronRight, + ChevronDown, + Plus, + Pencil, + Trash2, + Folder, + FolderOpen, + Tag, + Search, + RefreshCw, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + CategoryValue, + getCategoryTree, + createCategoryValue, + updateCategoryValue, + deleteCategoryValue, + CreateCategoryValueInput, +} from "@/lib/api/categoryTree"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; + +interface CategoryValueManagerTreeProps { + tableName: string; + columnName: string; + columnLabel: string; + onValueCountChange?: (count: number) => void; +} + +// 트리 노드 컴포넌트 +interface TreeNodeProps { + node: CategoryValue; + level: number; + expandedNodes: Set; + selectedValueId?: number; + searchQuery: string; + onToggle: (valueId: number) => void; + onSelect: (value: CategoryValue) => void; + onAdd: (parentValue: CategoryValue | null) => void; + onEdit: (value: CategoryValue) => void; + onDelete: (value: CategoryValue) => void; +} + +// 검색어가 노드 또는 하위에 매칭되는지 확인 +const nodeMatchesSearch = (node: CategoryValue, query: string): boolean => { + if (!query) return true; + const lowerQuery = query.toLowerCase(); + if (node.valueLabel.toLowerCase().includes(lowerQuery)) return true; + if (node.valueCode.toLowerCase().includes(lowerQuery)) return true; + if (node.children) { + return node.children.some((child) => nodeMatchesSearch(child, query)); + } + return false; +}; + +const TreeNode: React.FC = ({ + node, + level, + expandedNodes, + selectedValueId, + searchQuery, + onToggle, + onSelect, + onAdd, + onEdit, + onDelete, +}) => { + const hasChildren = node.children && node.children.length > 0; + const isExpanded = expandedNodes.has(node.valueId); + const isSelected = selectedValueId === node.valueId; + const canAddChild = node.depth < 3; + + // 검색 필터링 + if (searchQuery && !nodeMatchesSearch(node, searchQuery)) { + return null; + } + + // 깊이별 아이콘 + const getIcon = () => { + if (hasChildren) { + return isExpanded ? ( + + ) : ( + + ); + } + return ; + }; + + // 깊이별 라벨 + const getDepthLabel = () => { + switch (node.depth) { + case 1: + return "대분류"; + case 2: + return "중분류"; + case 3: + return "소분류"; + default: + return ""; + } + }; + + return ( +
+
onSelect(node)} + > + {/* 확장 토글 */} + + + {/* 아이콘 */} + {getIcon()} + + {/* 라벨 */} +
+ {node.valueLabel} + {getDepthLabel()} +
+ + {/* 비활성 표시 */} + {!node.isActive && ( + 비활성 + )} + + {/* 액션 버튼 */} +
+ {canAddChild && ( + + )} + + +
+
+ + {/* 자식 노드 */} + {hasChildren && isExpanded && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +export const CategoryValueManagerTree: React.FC = ({ + tableName, + columnName, + columnLabel, + onValueCountChange, +}) => { + // 상태 + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [selectedValue, setSelectedValue] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [showInactive, setShowInactive] = useState(false); + + // 모달 상태 + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [parentValue, setParentValue] = useState(null); + const [editingValue, setEditingValue] = useState(null); + const [deletingValue, setDeletingValue] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState({ + valueCode: "", + valueLabel: "", + description: "", + color: "", + isActive: true, + }); + + // 전체 값 개수 계산 + const countAllValues = useCallback((nodes: CategoryValue[]): number => { + let count = nodes.length; + for (const node of nodes) { + if (node.children) { + count += countAllValues(node.children); + } + } + return count; + }, []); + + // 활성 노드만 필터링 + const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => { + return nodes + .filter((node) => node.isActive !== false) + .map((node) => ({ + ...node, + children: node.children ? filterActiveNodes(node.children) : undefined, + })); + }, []); + + // 데이터 로드 + const loadTree = useCallback(async () => { + if (!tableName || !columnName) return; + + setLoading(true); + try { + const response = await getCategoryTree(tableName, columnName); + if (response.success && response.data) { + let filteredTree = response.data; + + // 비활성 필터링 + if (!showInactive) { + filteredTree = filterActiveNodes(response.data); + } + + setTree(filteredTree); + + // 1단계 노드는 기본 펼침 + const rootIds = new Set(filteredTree.map((n) => n.valueId)); + setExpandedNodes(rootIds); + + // 전체 개수 업데이트 + const totalCount = countAllValues(response.data); + onValueCountChange?.(totalCount); + } + } catch (error) { + console.error("카테고리 트리 로드 오류:", error); + } finally { + setLoading(false); + } + }, [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange]); + + useEffect(() => { + loadTree(); + }, [loadTree]); + + // 모든 노드 펼치기 + const expandAll = () => { + const allIds = new Set(); + const collectIds = (nodes: CategoryValue[]) => { + for (const node of nodes) { + allIds.add(node.valueId); + if (node.children) { + collectIds(node.children); + } + } + }; + collectIds(tree); + setExpandedNodes(allIds); + }; + + // 모든 노드 접기 + const collapseAll = () => { + setExpandedNodes(new Set()); + }; + + // 토글 핸들러 + const handleToggle = (valueId: number) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(valueId)) { + next.delete(valueId); + } else { + next.add(valueId); + } + return next; + }); + }; + + // 추가 모달 열기 + const handleOpenAddModal = (parent: CategoryValue | null) => { + setParentValue(parent); + setFormData({ + valueCode: "", + valueLabel: "", + description: "", + color: "", + isActive: true, + }); + setIsAddModalOpen(true); + }; + + // 수정 모달 열기 + const handleOpenEditModal = (value: CategoryValue) => { + setEditingValue(value); + setFormData({ + valueCode: value.valueCode, + valueLabel: value.valueLabel, + description: value.description || "", + color: value.color || "", + isActive: value.isActive, + }); + setIsEditModalOpen(true); + }; + + // 삭제 다이얼로그 열기 + const handleOpenDeleteDialog = (value: CategoryValue) => { + setDeletingValue(value); + setIsDeleteDialogOpen(true); + }; + + // 코드 자동 생성 함수 + const generateCode = () => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `CAT_${timestamp}_${random}`; + }; + + // 추가 처리 + const handleAdd = async () => { + if (!formData.valueLabel) { + toast.error("이름은 필수입니다"); + return; + } + + try { + // 코드 자동 생성 + const autoCode = generateCode(); + + const input: CreateCategoryValueInput = { + tableName, + columnName, + valueCode: autoCode, + valueLabel: formData.valueLabel, + parentValueId: parentValue?.valueId || null, + description: formData.description || undefined, + color: formData.color || undefined, + isActive: formData.isActive, + }; + + const response = await createCategoryValue(input); + if (response.success) { + toast.success("카테고리가 추가되었습니다"); + setIsAddModalOpen(false); + loadTree(); + if (parentValue) { + setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); + } + } else { + toast.error(response.error || "추가 실패"); + } + } catch (error) { + console.error("카테고리 추가 오류:", error); + toast.error("카테고리 추가 중 오류가 발생했습니다"); + } + }; + + // 수정 처리 + const handleEdit = async () => { + if (!editingValue) return; + + try { + // 코드는 변경하지 않음 (기존 코드 유지) + const response = await updateCategoryValue(editingValue.valueId, { + valueLabel: formData.valueLabel, + description: formData.description || undefined, + color: formData.color || undefined, + isActive: formData.isActive, + }); + + if (response.success) { + toast.success("카테고리가 수정되었습니다"); + setIsEditModalOpen(false); + loadTree(); + } else { + toast.error(response.error || "수정 실패"); + } + } catch (error) { + console.error("카테고리 수정 오류:", error); + toast.error("카테고리 수정 중 오류가 발생했습니다"); + } + }; + + // 삭제 처리 + const handleDelete = async () => { + if (!deletingValue) return; + + try { + const response = await deleteCategoryValue(deletingValue.valueId); + if (response.success) { + toast.success("카테고리가 삭제되었습니다"); + setIsDeleteDialogOpen(false); + setSelectedValue(null); + loadTree(); + } else { + toast.error(response.error || "삭제 실패"); + } + } catch (error) { + console.error("카테고리 삭제 오류:", error); + toast.error("카테고리 삭제 중 오류가 발생했습니다"); + } + }; + + return ( +
+ {/* 헤더 */} +
+

{columnLabel} 카테고리

+ +
+ + {/* 툴바 */} +
+ {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-sm" + /> +
+ + {/* 옵션 */} +
+
+ + +
+ +
+ + + +
+
+
+ + {/* 트리 */} +
+ {loading ? ( +
+
로딩 중...
+
+ ) : tree.length === 0 ? ( +
+ +

카테고리가 없습니다

+

상단의 대분류 추가 버튼을 클릭하여 시작하세요

+
+ ) : ( +
+ {tree.map((node) => ( + + ))} +
+ )} +
+ + + {/* 추가 모달 */} + + + + + {parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"} + + + {parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"} + + + +
+
+ + setFormData({ ...formData, valueLabel: e.target.value })} + placeholder="카테고리 이름을 입력하세요" + className="h-9 text-sm" + /> +

코드는 자동으로 생성됩니다

+
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="선택 사항" + className="h-9 text-sm" + /> +
+
+ setFormData({ ...formData, isActive: checked })} + /> + +
+
+ + + + + +
+
+ + {/* 수정 모달 */} + + + + 카테고리 수정 + 카테고리 정보를 수정합니다 + + +
+
+ + setFormData({ ...formData, valueLabel: e.target.value })} + className="h-9 text-sm" + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + className="h-9 text-sm" + /> +
+
+ setFormData({ ...formData, isActive: checked })} + /> + +
+
+ + + + + +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 카테고리 삭제 + + {deletingValue?.valueLabel}을(를) 삭제하시겠습니까? + {deletingValue?.children && deletingValue.children.length > 0 && ( + <> +
+ + 하위 카테고리 {deletingValue.children.length}개도 모두 함께 삭제됩니다. + + + )} +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +}; + +export default CategoryValueManagerTree; + diff --git a/frontend/contexts/ActiveTabContext.tsx b/frontend/contexts/ActiveTabContext.tsx index ef31be62..7b300923 100644 --- a/frontend/contexts/ActiveTabContext.tsx +++ b/frontend/contexts/ActiveTabContext.tsx @@ -144,3 +144,4 @@ export const useActiveTabOptional = () => { + diff --git a/frontend/hooks/useAutoFill.ts b/frontend/hooks/useAutoFill.ts index 4aeb7b38..82271b34 100644 --- a/frontend/hooks/useAutoFill.ts +++ b/frontend/hooks/useAutoFill.ts @@ -201,3 +201,4 @@ export function applyAutoFillToFormData( + diff --git a/frontend/lib/api/categoryTree.ts b/frontend/lib/api/categoryTree.ts new file mode 100644 index 00000000..e3e8e020 --- /dev/null +++ b/frontend/lib/api/categoryTree.ts @@ -0,0 +1,191 @@ +/** + * 카테고리 트리 API 클라이언트 (테스트용) + * - 트리 구조 CRUD 지원 + */ + +import { apiClient } from "./client"; + +// 카테고리 값 타입 +export interface CategoryValue { + valueId: number; + tableName: string; + columnName: string; + valueCode: string; + valueLabel: string; + valueOrder: number; + parentValueId: number | null; + depth: number; // 1=대분류, 2=중분류, 3=소분류 + path: string | null; + description: string | null; + color: string | null; + icon: string | null; + isActive: boolean; + isDefault: boolean; + companyCode: string; + createdAt: string; + updatedAt: string; + // 트리 구조용 + children?: CategoryValue[]; +} + +// 카테고리 값 생성 입력 +export interface CreateCategoryValueInput { + tableName: string; + columnName: string; + valueCode: string; + valueLabel: string; + valueOrder?: number; + parentValueId?: number | null; + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; +} + +// 카테고리 값 수정 입력 +export interface UpdateCategoryValueInput { + valueCode?: string; + valueLabel?: string; + valueOrder?: number; + parentValueId?: number | null; + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; +} + +// API 응답 타입 +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +/** + * 카테고리 트리 조회 + */ +export async function getCategoryTree( + tableName: string, + columnName: string +): Promise> { + try { + const response = await apiClient.get(`/category-tree/test/${tableName}/${columnName}`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 트리 조회 실패", + }; + } +} + +/** + * 카테고리 목록 조회 (플랫) + */ +export async function getCategoryList( + tableName: string, + columnName: string +): Promise> { + try { + const response = await apiClient.get(`/category-tree/test/${tableName}/${columnName}/flat`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 목록 조회 실패", + }; + } +} + +/** + * 카테고리 값 단일 조회 + */ +export async function getCategoryValue(valueId: number): Promise> { + try { + const response = await apiClient.get(`/category-tree/test/value/${valueId}`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 값 조회 실패", + }; + } +} + +/** + * 카테고리 값 생성 + */ +export async function createCategoryValue( + input: CreateCategoryValueInput +): Promise> { + try { + const response = await apiClient.post("/category-tree/test/value", input); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 값 생성 실패", + }; + } +} + +/** + * 카테고리 값 수정 + */ +export async function updateCategoryValue( + valueId: number, + input: UpdateCategoryValueInput +): Promise> { + try { + const response = await apiClient.put(`/category-tree/test/value/${valueId}`, input); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 값 수정 실패", + }; + } +} + +/** + * 카테고리 값 삭제 + */ +export async function deleteCategoryValue(valueId: number): Promise> { + try { + const response = await apiClient.delete(`/category-tree/test/value/${valueId}`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 값 삭제 실패", + }; + } +} + +/** + * 테이블의 카테고리 컬럼 목록 조회 + */ +export async function getCategoryColumns( + tableName: string +): Promise> { + try { + const response = await apiClient.get(`/category-tree/test/columns/${tableName}`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "카테고리 컬럼 조회 실패", + }; + } +} + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 3201639c..c56461ef 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -105,6 +105,7 @@ import "./v2-rack-structure/RackStructureRenderer"; import "./v2-location-swap-selector/LocationSwapSelectorRenderer"; import "./v2-table-search-widget"; import "./v2-tabs-widget/tabs-component"; +import "./v2-category-manager/V2CategoryManagerRenderer"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx new file mode 100644 index 00000000..aee1946d --- /dev/null +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx @@ -0,0 +1,206 @@ +"use client"; + +/** + * V2 카테고리 관리 컴포넌트 + * - 트리 구조 기반 카테고리 값 관리 + * - 3단계 계층 구조 지원 (대분류/중분류/소분류) + */ + +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; +import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; +import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; +import { GripVertical, LayoutList, TreeDeciduous } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types"; + +interface V2CategoryManagerComponentProps { + tableName?: string; + menuObjid?: number; + selectedScreen?: { tableName?: string; menuObjid?: number; menuId?: number }; + config?: Partial; + componentConfig?: Partial; + [key: string]: unknown; +} + +export function V2CategoryManagerComponent({ + tableName, + menuObjid, + selectedScreen, + config: externalConfig, + componentConfig, + ...props +}: V2CategoryManagerComponentProps) { + // 설정 병합 (componentConfig도 포함) + const config: V2CategoryManagerConfig = { + ...defaultV2CategoryManagerConfig, + ...externalConfig, + ...componentConfig, + }; + + // tableName 우선순위: props > selectedScreen > componentConfig + const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || ""; + + // menuObjid 우선순위: props > selectedScreen + const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined; + const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid; + + // 디버그 로그 + useEffect(() => { + console.log("🔍 V2CategoryManagerComponent props:", { + tableName, + menuObjid, + selectedScreen, + effectiveTableName, + effectiveMenuObjid, + config, + }); + }, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]); + + // 선택된 컬럼 상태 + const [selectedColumn, setSelectedColumn] = useState<{ + uniqueKey: string; + columnName: string; + columnLabel: string; + tableName: string; + } | null>(null); + + // 뷰 모드 상태 + const [viewMode, setViewMode] = useState(config.viewMode); + + // 좌측 패널 너비 상태 + const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth); + const containerRef = useRef(null); + const isDraggingRef = useRef(false); + + // 리사이저 핸들러 + const handleMouseDown = useCallback(() => { + isDraggingRef.current = true; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, []); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDraggingRef.current || !containerRef.current) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; + + if (newLeftWidth >= 10 && newLeftWidth <= 40) { + setLeftWidth(newLeftWidth); + } + }, []); + + const handleMouseUp = useCallback(() => { + isDraggingRef.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + + // 컬럼 선택 핸들러 + const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => { + const columnName = uniqueKey.split(".")[1]; + setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName }); + }, []); + + return ( +
+ {/* 좌측: 카테고리 컬럼 리스트 */} + {config.showColumnList && ( + <> +
+ +
+ + {/* 리사이저 */} +
+ +
+ + )} + + {/* 우측: 카테고리 값 관리 */} +
+ {/* 뷰 모드 토글 */} + {config.showViewModeToggle && ( +
+ 보기 방식: +
+ + +
+
+ )} + + {/* 카테고리 값 관리 컴포넌트 */} +
+ {selectedColumn ? ( + viewMode === "tree" ? ( + + ) : ( + + ) + ) : ( +
+
+ +

+ {config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} +

+
+
+ )} +
+
+
+ ); +} + +export default V2CategoryManagerComponent; + diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerConfigPanel.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerConfigPanel.tsx new file mode 100644 index 00000000..6fb3ed32 --- /dev/null +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerConfigPanel.tsx @@ -0,0 +1,126 @@ +"use client"; + +/** + * V2 카테고리 관리 설정 패널 + */ + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types"; + +interface V2CategoryManagerConfigPanelProps { + config: Partial; + onChange: (config: Partial) => void; +} + +export function V2CategoryManagerConfigPanel({ config: externalConfig, onChange }: V2CategoryManagerConfigPanelProps) { + const config: V2CategoryManagerConfig = { + ...defaultV2CategoryManagerConfig, + ...externalConfig, + }; + + const handleChange = (key: K, value: V2CategoryManagerConfig[K]) => { + onChange({ ...config, [key]: value }); + }; + + return ( +
+ {/* 뷰 모드 설정 */} +
+

뷰 모드 설정

+ +
+
+ + +

컴포넌트가 처음 표시될 때의 기본 뷰 모드

+
+ +
+
+ +

트리/목록 전환 버튼 표시

+
+ handleChange("showViewModeToggle", checked)} /> +
+
+
+ + {/* 트리 설정 */} +
+

트리 설정

+ +
+
+ + +
+ +
+
+ +

비활성화된 카테고리도 표시

+
+ handleChange("showInactiveItems", checked)} /> +
+
+
+ + {/* 레이아웃 설정 */} +
+

레이아웃 설정

+ +
+
+
+ +

좌측 카테고리 컬럼 목록 패널

+
+ handleChange("showColumnList", checked)} /> +
+ + {config.showColumnList && ( +
+ + handleChange("leftPanelWidth", Number(e.target.value))} + className="mt-1 h-8 text-xs" + /> +

10~40% 범위에서 설정 가능

+
+ )} +
+
+
+ ); +} + +export default V2CategoryManagerConfigPanel; + diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerRenderer.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerRenderer.tsx new file mode 100644 index 00000000..3fcd6bfd --- /dev/null +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerRenderer.tsx @@ -0,0 +1,13 @@ +"use client"; + +/** + * V2 카테고리 관리 렌더러 + * - 컴포넌트 레지스트리 자동 등록 + */ + +import { V2CategoryManagerDefinition } from "./index"; +import { ComponentRegistry } from "../../ComponentRegistry"; + +// 컴포넌트 레지스트리에 등록 +ComponentRegistry.registerComponent(V2CategoryManagerDefinition); + diff --git a/frontend/lib/registry/components/v2-category-manager/index.ts b/frontend/lib/registry/components/v2-category-manager/index.ts new file mode 100644 index 00000000..3f8a9be3 --- /dev/null +++ b/frontend/lib/registry/components/v2-category-manager/index.ts @@ -0,0 +1,36 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { V2CategoryManagerComponent } from "./V2CategoryManagerComponent"; +import { V2CategoryManagerConfigPanel } from "./V2CategoryManagerConfigPanel"; +import { defaultV2CategoryManagerConfig } from "./types"; + +/** + * V2 카테고리 관리 컴포넌트 정의 + * - 트리 구조 기반 카테고리 값 관리 + * - 3단계 계층 구조 지원 (대분류/중분류/소분류) + */ +export const V2CategoryManagerDefinition = createComponentDefinition({ + id: "v2-category-manager", + name: "카테고리 관리 (V2)", + nameEng: "Category Manager V2", + description: "트리 구조 기반 카테고리 값 관리 컴포넌트 (3단계 계층 지원)", + category: ComponentCategory.DISPLAY, + webType: "category", + component: V2CategoryManagerComponent, + defaultConfig: defaultV2CategoryManagerConfig, + defaultSize: { width: 1000, height: 600 }, + configPanel: V2CategoryManagerConfigPanel, + icon: "FolderTree", + tags: ["카테고리", "트리", "계층", "분류", "관리"], + version: "2.0.0", + author: "개발팀", + documentation: "", +}); + +// 타입 내보내기 +export type { V2CategoryManagerConfig, CategoryValue, ViewMode } from "./types"; +export { V2CategoryManagerComponent } from "./V2CategoryManagerComponent"; +export { V2CategoryManagerConfigPanel } from "./V2CategoryManagerConfigPanel"; + diff --git a/frontend/lib/registry/components/v2-category-manager/types.ts b/frontend/lib/registry/components/v2-category-manager/types.ts new file mode 100644 index 00000000..5fdd91a3 --- /dev/null +++ b/frontend/lib/registry/components/v2-category-manager/types.ts @@ -0,0 +1,54 @@ +/** + * V2 카테고리 관리 컴포넌트 타입 정의 + */ + +// 카테고리 값 타입 +export interface CategoryValue { + valueId: number; + tableName: string; + columnName: string; + valueCode: string; + valueLabel: string; + valueOrder: number; + parentValueId: number | null; + depth: number; + path: string | null; + description: string | null; + color: string | null; + icon: string | null; + isActive: boolean; + isDefault: boolean; + companyCode: string; + createdAt: string; + updatedAt: string; + children?: CategoryValue[]; +} + +// 뷰 모드 타입 +export type ViewMode = "tree" | "list"; + +// 컴포넌트 설정 타입 +export interface V2CategoryManagerConfig { + tableName?: string; + columnName?: string; + menuObjid?: number; + viewMode: ViewMode; + showViewModeToggle: boolean; + defaultExpandLevel: number; + showInactiveItems: boolean; + leftPanelWidth: number; + showColumnList: boolean; + height: string | number; +} + +// 기본 설정 +export const defaultV2CategoryManagerConfig: V2CategoryManagerConfig = { + viewMode: "tree", + showViewModeToggle: true, + defaultExpandLevel: 1, + showInactiveItems: false, + leftPanelWidth: 15, + showColumnList: true, + height: "100%", +}; + diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 82dc47d6..8d860be9 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1693,3 +1693,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 03605468..c88ab484 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -540,3 +540,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index c7c676c1..5819dc8e 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -527,3 +527,4 @@ function ScreenViewPage() { +