diff --git a/.cursor/rules/ai-developer-collaboration-rules.mdc b/.cursor/rules/ai-developer-collaboration-rules.mdc index ccdcc9fc..b1da651a 100644 --- a/.cursor/rules/ai-developer-collaboration-rules.mdc +++ b/.cursor/rules/ai-developer-collaboration-rules.mdc @@ -278,4 +278,117 @@ const hiddenColumns = new Set([ --- +## 11. 화면관리 시스템 위젯 개발 가이드 + +### 위젯 크기 설정의 핵심 원칙 + +화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다. + +#### ✅ 올바른 크기 설정 패턴 + +```tsx +// 위젯 컴포넌트 내부 +export function YourWidget({ component }: YourWidgetProps) { + return ( +
+ {/* 위젯 내용 */} +
+ ); +} +``` + +#### ❌ 잘못된 크기 설정 패턴 + +```tsx +// 이렇게 하면 안 됩니다! +
+``` + +### 이유 + +1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어: + + ```tsx + const baseStyle = { + left: `${position.x}px`, + top: `${position.y}px`, + width: getWidth(), // size.width 사용 + height: getHeight(), // size.height 사용 + }; + ``` + +2. 위젯 내부에서 크기를 다시 설정하면: + - 중복 설정으로 인한 충돌 + - 내부 컨텐츠가 설정한 크기보다 작게 표시됨 + - 편집기에서 설정한 크기와 실제 렌더링 크기 불일치 + +### 위젯이 관리해야 할 스타일 + +위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다: + +- ✅ `padding`: 내부 여백 +- ✅ `backgroundColor`: 배경색 +- ✅ `border`, `borderRadius`: 테두리 +- ✅ `gap`: 자식 요소 간격 +- ✅ `flexDirection`, `alignItems`: 레이아웃 방향 + +### 위젯 등록 시 defaultSize + +```tsx +ComponentRegistry.registerComponent({ + id: "your-widget", + name: "위젯 이름", + category: "utility", + defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수) + component: YourWidget, + defaultProps: { + style: { + padding: "0.75rem", + // width, height는 defaultSize로 제어되므로 여기 불필요 + }, + }, +}); +``` + +### 레이아웃 구조 + +```tsx +// 전체 높이를 차지하고 내부 요소를 정렬 +
+ {/* 왼쪽 컨텐츠 */} +
{/* ... */}
+ + {/* 오른쪽 버튼들 */} +
+ {/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */} +
+
+``` + +### 체크리스트 + +위젯 개발 시 다음을 확인하세요: + +- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용 +- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거** +- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리 +- [ ] `defaultSize`에 적절한 기본 크기 설정 +- [ ] 양끝 정렬이 필요하면 `justify-between` 사용 +- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용 + +--- + **이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!** diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 616e0c6c..b0db2059 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -20,8 +20,9 @@ export class CommonCodeController { */ async getCategories(req: AuthenticatedRequest, res: Response) { try { - const { search, isActive, page = "1", size = "20" } = req.query; + const { search, isActive, page = "1", size = "20", menuObjid } = req.query; const userCompanyCode = req.user?.companyCode; + const menuObjidNum = menuObjid ? Number(menuObjid) : undefined; const categories = await this.commonCodeService.getCategories( { @@ -35,7 +36,8 @@ export class CommonCodeController { page: parseInt(page as string), size: parseInt(size as string), }, - userCompanyCode + userCompanyCode, + menuObjidNum ); return res.json({ @@ -61,8 +63,9 @@ export class CommonCodeController { async getCodes(req: AuthenticatedRequest, res: Response) { try { const { categoryCode } = req.params; - const { search, isActive, page, size } = req.query; + const { search, isActive, page, size, menuObjid } = req.query; const userCompanyCode = req.user?.companyCode; + const menuObjidNum = menuObjid ? Number(menuObjid) : undefined; const result = await this.commonCodeService.getCodes( categoryCode, @@ -77,7 +80,8 @@ export class CommonCodeController { page: page ? parseInt(page as string) : undefined, size: size ? parseInt(size as string) : undefined, }, - userCompanyCode + userCompanyCode, + menuObjidNum ); // 프론트엔드가 기대하는 형식으로 데이터 변환 @@ -131,6 +135,7 @@ export class CommonCodeController { const categoryData: CreateCategoryData = req.body; const userId = req.user?.userId || "SYSTEM"; const companyCode = req.user?.companyCode || "*"; + const menuObjid = req.body.menuObjid; // 입력값 검증 if (!categoryData.categoryCode || !categoryData.categoryName) { @@ -140,10 +145,18 @@ export class CommonCodeController { }); } + if (!menuObjid) { + return res.status(400).json({ + success: false, + message: "메뉴 OBJID는 필수입니다.", + }); + } + const category = await this.commonCodeService.createCategory( categoryData, userId, - companyCode + companyCode, + Number(menuObjid) ); return res.status(201).json({ @@ -263,6 +276,7 @@ export class CommonCodeController { const codeData: CreateCodeData = req.body; const userId = req.user?.userId || "SYSTEM"; const companyCode = req.user?.companyCode || "*"; + const menuObjid = req.body.menuObjid; // 입력값 검증 if (!codeData.codeValue || !codeData.codeName) { @@ -272,11 +286,19 @@ export class CommonCodeController { }); } + if (!menuObjid) { + return res.status(400).json({ + success: false, + message: "메뉴 OBJID는 필수입니다.", + }); + } + const code = await this.commonCodeService.createCode( categoryCode, codeData, userId, - companyCode + companyCode, + Number(menuObjid) ); return res.status(201).json({ diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 556d09df..55c19353 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -27,12 +27,24 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate const companyCode = req.user!.companyCode; const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; + logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode }); + try { const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); + + logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { + companyCode, + menuObjid, + rulesCount: rules.length + }); + return res.json({ success: true, data: rules }); } catch (error: any) { - logger.error("메뉴별 사용 가능한 규칙 조회 실패", { + logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { error: error.message, + errorCode: error.code, + errorStack: error.stack, + companyCode, menuObjid, }); return res.status(500).json({ success: false, error: error.message }); @@ -100,6 +112,17 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo const userId = req.user!.userId; const ruleConfig = req.body; + logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", { + companyCode, + userId, + ruleId: ruleConfig.ruleId, + ruleName: ruleConfig.ruleName, + scopeType: ruleConfig.scopeType, + menuObjid: ruleConfig.menuObjid, + tableName: ruleConfig.tableName, + partsCount: ruleConfig.parts?.length, + }); + try { if (!ruleConfig.ruleId || !ruleConfig.ruleName) { return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); @@ -110,12 +133,22 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo } const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId); + + logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { + ruleId: newRule.ruleId, + menuObjid: newRule.menuObjid, + }); + return res.status(201).json({ success: true, data: newRule }); } catch (error: any) { if (error.code === "23505") { return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); } - logger.error("규칙 생성 실패", { error: error.message }); + logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", { + error: error.message, + stack: error.stack, + code: error.code, + }); return res.status(500).json({ success: false, error: error.message }); } }); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index f7900b94..95277664 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -60,6 +60,29 @@ export const getScreen = async ( } }; +// 화면에 할당된 메뉴 조회 +export const getScreenMenu = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + + const menuInfo = await screenManagementService.getMenuByScreen( + parseInt(id), + companyCode + ); + + res.json({ success: true, data: menuInfo }); + } catch (error) { + console.error("화면 메뉴 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 메뉴 조회에 실패했습니다." }); + } +}; + // 화면 생성 export const createScreen = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 865f7672..ffb6a5a4 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -32,18 +32,31 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons /** * 카테고리 값 목록 조회 (메뉴 스코프 적용) + * + * Query Parameters: + * - menuObjid: 메뉴 OBJID (선택사항, 제공 시 형제 메뉴의 카테고리 값 포함) + * - includeInactive: 비활성 값 포함 여부 */ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user!.companyCode; const { tableName, columnName } = req.params; const includeInactive = req.query.includeInactive === "true"; + const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined; + + logger.info("카테고리 값 조회 요청", { + tableName, + columnName, + menuObjid, + companyCode, + }); const values = await tableCategoryValueService.getCategoryValues( tableName, columnName, companyCode, - includeInactive + includeInactive, + menuObjid // ← menuObjid 전달 ); return res.json({ @@ -61,18 +74,37 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response }; /** - * 카테고리 값 추가 + * 카테고리 값 추가 (메뉴 스코프) + * + * Body: + * - menuObjid: 메뉴 OBJID (필수) + * - 나머지 카테고리 값 정보 */ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const value = req.body; + const { menuObjid, ...value } = req.body; + + if (!menuObjid) { + return res.status(400).json({ + success: false, + message: "menuObjid는 필수입니다", + }); + } + + logger.info("카테고리 값 추가 요청", { + tableName: value.tableName, + columnName: value.columnName, + menuObjid, + companyCode, + }); const newValue = await tableCategoryValueService.addCategoryValue( value, companyCode, - userId + userId, + Number(menuObjid) // ← menuObjid 전달 ); return res.status(201).json({ diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 8b1f859d..85159dc2 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1599,3 +1599,116 @@ export async function toggleLogTable( res.status(500).json(response); } } + +/** + * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * + * @route GET /api/table-management/menu/:menuObjid/category-columns + * @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회 + */ +export async function getCategoryColumnsByMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const companyCode = req.user?.companyCode; + + logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); + + if (!menuObjid) { + res.status(400).json({ + success: false, + message: "메뉴 OBJID가 필요합니다.", + }); + return; + } + + // 1. 형제 메뉴 조회 + const { getSiblingMenuObjids } = await import("../services/menuService"); + const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); + + logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids }); + + // 2. 형제 메뉴들이 사용하는 테이블 조회 + const { getPool } = await import("../database/db"); + const pool = getPool(); + + const tablesQuery = ` + SELECT DISTINCT sd.table_name + FROM screen_menu_assignments sma + INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id + WHERE sma.menu_objid = ANY($1) + AND sma.company_code = $2 + AND sd.table_name IS NOT NULL + `; + + const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + const tableNames = tablesResult.rows.map((row: any) => row.table_name); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + if (tableNames.length === 0) { + res.json({ + success: true, + data: [], + message: "형제 메뉴에 연결된 테이블이 없습니다.", + }); + return; + } + + // 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함) + logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode }); + + const columnsQuery = ` + SELECT + ttc.table_name AS "tableName", + COALESCE( + tl.table_label, + initcap(replace(ttc.table_name, '_', ' ')) + ) AS "tableLabel", + ttc.column_name AS "columnName", + COALESCE( + cl.column_label, + initcap(replace(ttc.column_name, '_', ' ')) + ) AS "columnLabel", + ttc.input_type AS "inputType" + FROM table_type_columns ttc + LEFT JOIN column_labels cl + ON ttc.table_name = cl.table_name + AND ttc.column_name = cl.column_name + LEFT JOIN table_labels tl + ON ttc.table_name = tl.table_name + WHERE ttc.table_name = ANY($1) + AND ttc.company_code = $2 + AND ttc.input_type = 'category' + ORDER BY ttc.table_name, ttc.column_name + `; + + logger.info("🔍 카테고리 컬럼 쿼리 실행 중..."); + const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); + logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length }); + + logger.info("✅ 카테고리 컬럼 조회 완료", { + columnCount: columnsResult.rows.length + }); + + res.json({ + success: true, + data: columnsResult.rows, + message: "카테고리 컬럼 조회 성공", + }); + } catch (error: any) { + logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패"); + logger.error("에러 메시지:", error.message); + logger.error("에러 스택:", error.stack); + logger.error("에러 전체:", error); + + res.status(500).json({ + success: false, + message: "카테고리 컬럼 조회에 실패했습니다.", + error: error.message, + stack: error.stack, // 디버깅용 + }); + } +} diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 3fed9129..e307ccc5 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -3,6 +3,7 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { getScreens, getScreen, + getScreenMenu, createScreen, updateScreen, updateScreenInfo, @@ -33,6 +34,7 @@ router.use(authenticateToken); // 화면 관리 router.get("/screens", getScreens); router.get("/screens/:id", getScreen); +router.get("/screens/:id/menu", getScreenMenu); // 화면에 할당된 메뉴 조회 router.post("/screens", createScreen); router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 0ec8c162..5ea98489 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -23,6 +23,7 @@ import { getLogConfig, getLogData, toggleLogTable, + getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -187,4 +188,14 @@ router.get("/tables/:tableName/log", getLogData); */ router.post("/tables/:tableName/log/toggle", toggleLogTable); +// ======================================== +// 메뉴 기반 카테고리 관리 API +// ======================================== + +/** + * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * GET /api/table-management/menu/:menuObjid/category-columns + */ +router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); + export default router; diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index 8c02a60d..8cbd8a29 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -23,7 +23,8 @@ export interface CodeInfo { description?: string | null; sort_order: number; is_active: string; - company_code: string; // 추가 + company_code: string; + menu_objid?: number | null; // 메뉴 기반 코드 관리용 created_date?: Date | null; created_by?: string | null; updated_date?: Date | null; @@ -66,7 +67,7 @@ export class CommonCodeService { /** * 카테고리 목록 조회 */ - async getCategories(params: GetCategoriesParams, userCompanyCode?: string) { + async getCategories(params: GetCategoriesParams, userCompanyCode?: string, menuObjid?: number) { try { const { search, isActive, page = 1, size = 20 } = params; @@ -74,6 +75,16 @@ export class CommonCodeService { const values: any[] = []; let paramIndex = 1; + // 메뉴별 필터링 (형제 메뉴 포함) + if (menuObjid) { + const { getSiblingMenuObjids } = await import('./menuService'); + const siblingMenuObjids = await getSiblingMenuObjids(menuObjid); + whereConditions.push(`menu_objid = ANY($${paramIndex})`); + values.push(siblingMenuObjids); + paramIndex++; + logger.info(`메뉴별 코드 카테고리 필터링: ${menuObjid}, 형제 메뉴: ${siblingMenuObjids.join(', ')}`); + } + // 회사별 필터링 (최고 관리자가 아닌 경우) if (userCompanyCode && userCompanyCode !== "*") { whereConditions.push(`company_code = $${paramIndex}`); @@ -142,15 +153,43 @@ export class CommonCodeService { async getCodes( categoryCode: string, params: GetCodesParams, - userCompanyCode?: string + userCompanyCode?: string, + menuObjid?: number ) { try { const { search, isActive, page = 1, size = 20 } = params; + logger.info(`🔍 [getCodes] 코드 조회 시작:`, { + categoryCode, + menuObjid, + hasMenuObjid: !!menuObjid, + userCompanyCode, + search, + isActive, + page, + size, + }); + const whereConditions: string[] = ["code_category = $1"]; const values: any[] = [categoryCode]; let paramIndex = 2; + // 메뉴별 필터링 (형제 메뉴 포함) + if (menuObjid) { + const { getSiblingMenuObjids } = await import('./menuService'); + const siblingMenuObjids = await getSiblingMenuObjids(menuObjid); + whereConditions.push(`menu_objid = ANY($${paramIndex})`); + values.push(siblingMenuObjids); + paramIndex++; + logger.info(`📋 [getCodes] 메뉴별 코드 필터링:`, { + menuObjid, + siblingMenuObjids, + siblingCount: siblingMenuObjids.length, + }); + } else { + logger.warn(`⚠️ [getCodes] menuObjid 없음 - 전역 코드 조회`); + } + // 회사별 필터링 (최고 관리자가 아닌 경우) if (userCompanyCode && userCompanyCode !== "*") { whereConditions.push(`company_code = $${paramIndex}`); @@ -178,6 +217,13 @@ export class CommonCodeService { const offset = (page - 1) * size; + logger.info(`📝 [getCodes] 실행할 쿼리:`, { + whereClause, + values, + whereConditions, + paramIndex, + }); + // 코드 조회 const codes = await query( `SELECT * FROM code_info @@ -196,9 +242,20 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` + `✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})` ); + logger.info(`📊 [getCodes] 조회된 코드 상세:`, { + categoryCode, + menuObjid, + codes: codes.map((c) => ({ + code_value: c.code_value, + code_name: c.code_name, + menu_objid: c.menu_objid, + company_code: c.company_code, + })), + }); + return { data: codes, total }; } catch (error) { logger.error(`코드 조회 중 오류 (${categoryCode}):`, error); @@ -212,14 +269,15 @@ export class CommonCodeService { async createCategory( data: CreateCategoryData, createdBy: string, - companyCode: string + companyCode: string, + menuObjid: number ) { try { const category = await queryOne( `INSERT INTO code_category (category_code, category_name, category_name_eng, description, sort_order, - is_active, company_code, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, NOW(), NOW()) + is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, $9, NOW(), NOW()) RETURNING *`, [ data.categoryCode, @@ -227,6 +285,7 @@ export class CommonCodeService { data.categoryNameEng || null, data.description || null, data.sortOrder || 0, + menuObjid, companyCode, createdBy, createdBy, @@ -234,7 +293,7 @@ export class CommonCodeService { ); logger.info( - `카테고리 생성 완료: ${data.categoryCode} (회사: ${companyCode})` + `카테고리 생성 완료: ${data.categoryCode} (메뉴: ${menuObjid}, 회사: ${companyCode})` ); return category; } catch (error) { @@ -352,14 +411,15 @@ export class CommonCodeService { categoryCode: string, data: CreateCodeData, createdBy: string, - companyCode: string + companyCode: string, + menuObjid: number ) { try { const code = await queryOne( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, - is_active, company_code, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, NOW(), NOW()) + is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW()) RETURNING *`, [ categoryCode, @@ -368,6 +428,7 @@ export class CommonCodeService { data.codeNameEng || null, data.description || null, data.sortOrder || 0, + menuObjid, companyCode, createdBy, createdBy, @@ -375,7 +436,7 @@ export class CommonCodeService { ); logger.info( - `코드 생성 완료: ${categoryCode}.${data.codeValue} (회사: ${companyCode})` + `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})` ); return code; } catch (error) { diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts index 2ed01231..c7a611d3 100644 --- a/backend-node/src/services/ddlExecutionService.ts +++ b/backend-node/src/services/ddlExecutionService.ts @@ -104,7 +104,7 @@ export class DDLExecutionService { await this.saveTableMetadata(client, tableName, description); // 5-3. 컬럼 메타데이터 저장 - await this.saveColumnMetadata(client, tableName, columns); + await this.saveColumnMetadata(client, tableName, columns, userCompanyCode); }); // 6. 성공 로그 기록 @@ -272,7 +272,7 @@ export class DDLExecutionService { await client.query(ddlQuery); // 6-2. 컬럼 메타데이터 저장 - await this.saveColumnMetadata(client, tableName, [column]); + await this.saveColumnMetadata(client, tableName, [column], userCompanyCode); }); // 7. 성공 로그 기록 @@ -446,7 +446,8 @@ CREATE TABLE "${tableName}" (${baseColumns}, private async saveColumnMetadata( client: any, tableName: string, - columns: CreateColumnDefinition[] + columns: CreateColumnDefinition[], + companyCode: string ): Promise { // 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성 await client.query( @@ -508,19 +509,19 @@ CREATE TABLE "${tableName}" (${baseColumns}, await client.query( ` INSERT INTO table_type_columns ( - table_name, column_name, input_type, detail_settings, + table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date ) VALUES ( - $1, $2, $3, '{}', - 'Y', $4, now(), now() + $1, $2, $3, $4, '{}', + 'Y', $5, now(), now() ) - ON CONFLICT (table_name, column_name) + ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET - input_type = $3, - display_order = $4, + input_type = $4, + display_order = $5, updated_date = now() `, - [tableName, defaultCol.name, defaultCol.inputType, defaultCol.order] + [tableName, defaultCol.name, companyCode, defaultCol.inputType, defaultCol.order] ); } @@ -535,20 +536,20 @@ CREATE TABLE "${tableName}" (${baseColumns}, await client.query( ` INSERT INTO table_type_columns ( - table_name, column_name, input_type, detail_settings, + table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date ) VALUES ( - $1, $2, $3, $4, - 'Y', $5, now(), now() + $1, $2, $3, $4, $5, + 'Y', $6, now(), now() ) - ON CONFLICT (table_name, column_name) + ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET - input_type = $3, - detail_settings = $4, - display_order = $5, + input_type = $4, + detail_settings = $5, + display_order = $6, updated_date = now() `, - [tableName, column.name, inputType, detailSettings, i] + [tableName, column.name, companyCode, inputType, detailSettings, i] ); } diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 6877fedd..f1795ba0 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -24,20 +24,19 @@ export class EntityJoinService { try { logger.info(`Entity 컬럼 감지 시작: ${tableName}`); - // column_labels에서 entity 타입인 컬럼들 조회 + // column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용) const entityColumns = await query<{ column_name: string; + input_type: string; reference_table: string; reference_column: string; display_column: string | null; }>( - `SELECT column_name, reference_table, reference_column, display_column + `SELECT column_name, input_type, reference_table, reference_column, display_column FROM column_labels WHERE table_name = $1 - AND web_type = $2 - AND reference_table IS NOT NULL - AND reference_column IS NOT NULL`, - [tableName, "entity"] + AND input_type IN ('entity', 'category')`, + [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); @@ -77,18 +76,34 @@ export class EntityJoinService { } for (const column of entityColumns) { + // 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정 + let referenceTable = column.reference_table; + let referenceColumn = column.reference_column; + let displayColumn = column.display_column; + + if (column.input_type === 'category') { + // 카테고리 타입: reference 정보가 비어있어도 자동 설정 + referenceTable = referenceTable || 'table_column_category_values'; + referenceColumn = referenceColumn || 'value_code'; + displayColumn = displayColumn || 'value_label'; + + logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { + referenceTable, + referenceColumn, + displayColumn, + }); + } + logger.info(`🔍 Entity 컬럼 상세 정보:`, { column_name: column.column_name, - reference_table: column.reference_table, - reference_column: column.reference_column, - display_column: column.display_column, + input_type: column.input_type, + reference_table: referenceTable, + reference_column: referenceColumn, + display_column: displayColumn, }); - if ( - !column.column_name || - !column.reference_table || - !column.reference_column - ) { + if (!column.column_name || !referenceTable || !referenceColumn) { + logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`); continue; } @@ -112,27 +127,28 @@ export class EntityJoinService { separator, screenConfig, }); - } else if (column.display_column && column.display_column !== "none") { + } else if (displayColumn && displayColumn !== "none") { // 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만) - displayColumns = [column.display_column]; + displayColumns = [displayColumn]; logger.info( - `🔧 기존 display_column 사용: ${column.column_name} → ${column.display_column}` + `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { // display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정 - // 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용 - let defaultDisplayColumn = column.reference_column; - if (column.reference_table === "dept_info") { + let defaultDisplayColumn = referenceColumn; + if (referenceTable === "dept_info") { defaultDisplayColumn = "dept_name"; - } else if (column.reference_table === "company_info") { + } else if (referenceTable === "company_info") { defaultDisplayColumn = "company_name"; - } else if (column.reference_table === "user_info") { + } else if (referenceTable === "user_info") { defaultDisplayColumn = "user_name"; + } else if (referenceTable === "category_values") { + defaultDisplayColumn = "category_name"; } displayColumns = [defaultDisplayColumn]; logger.info( - `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${column.reference_table})` + `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})` ); logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns); } @@ -143,8 +159,8 @@ export class EntityJoinService { const joinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: column.column_name, - referenceTable: column.reference_table, - referenceColumn: column.reference_column, + referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용 + referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용 displayColumns: displayColumns, displayColumn: displayColumns[0], // 하위 호환성 aliasColumn: aliasColumn, @@ -245,11 +261,14 @@ export class EntityJoinService { config.displayColumn, ]; const separator = config.separator || " - "; + + // 결과 컬럼 배열 (aliasColumn + _label 필드) + const resultColumns: string[] = []; if (displayColumns.length === 0 || !displayColumns[0]) { // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 // 조인 테이블의 referenceColumn을 기본값으로 사용 - return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`; + resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`); } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; @@ -265,12 +284,18 @@ export class EntityJoinService { "company_name", "sales_yn", "status", + "value_label", // table_column_category_values + "user_name", // user_info ].includes(col); if (isJoinTableColumn) { - return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`; + resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`); + + // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) + // sourceColumn_label 형식으로 추가 + resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`); } else { - return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`; + resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`); } } else { // 여러 컬럼인 경우 CONCAT으로 연결 @@ -291,6 +316,8 @@ export class EntityJoinService { "company_name", "sales_yn", "status", + "value_label", // table_column_category_values + "user_name", // user_info ].includes(col); if (isJoinTableColumn) { @@ -303,8 +330,11 @@ export class EntityJoinService { }) .join(` || '${separator}' || `); - return `(${concatParts}) AS ${config.aliasColumn}`; + resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); } + + // 모든 resultColumns를 반환 + return resultColumns.join(", "); }) .join(", "); @@ -320,6 +350,12 @@ export class EntityJoinService { const joinClauses = uniqueReferenceTableConfigs .map((config) => { const alias = aliasMap.get(config.referenceTable); + + // table_column_category_values는 특별한 조인 조건 필요 + if (config.referenceTable === 'table_column_category_values') { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`; + } + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); @@ -380,6 +416,14 @@ export class EntityJoinService { return "join"; } + // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 + if (config.referenceTable === 'table_column_category_values') { + logger.info( + `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` + ); + return "join"; + } + // 참조 테이블의 캐시 가능성 확인 const displayCol = config.displayColumn || diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts new file mode 100644 index 00000000..86df579c --- /dev/null +++ b/backend-node/src/services/menuService.ts @@ -0,0 +1,179 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 메뉴 관련 유틸리티 서비스 + * + * 메뉴 스코프 기반 데이터 공유를 위한 형제 메뉴 조회 기능 제공 + */ + +/** + * 메뉴의 형제 메뉴 및 자식 메뉴 OBJID 목록 조회 + * (같은 부모를 가진 메뉴들 + 자식 메뉴들) + * + * 메뉴 스코프 규칙: + * - 같은 부모를 가진 형제 메뉴들은 카테고리/채번규칙을 공유 + * - 자식 메뉴의 데이터도 부모 메뉴에서 조회 가능 (3레벨까지만 존재) + * - 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환 + * - 메뉴를 찾을 수 없으면 안전하게 자기 자신만 반환 + * + * @param menuObjid 현재 메뉴의 OBJID + * @returns 형제 메뉴 + 자식 메뉴 OBJID 배열 (자기 자신 포함, 정렬됨) + * + * @example + * // 영업관리 (200) + * // ├── 고객관리 (201) + * // │ └── 고객등록 (211) + * // ├── 계약관리 (202) + * // └── 주문관리 (203) + * + * await getSiblingMenuObjids(201); + * // 결과: [201, 202, 203, 211] - 형제(202, 203) + 자식(211) + */ +export async function getSiblingMenuObjids(menuObjid: number): Promise { + const pool = getPool(); + + try { + logger.debug("메뉴 스코프 조회 시작", { menuObjid }); + + // 1. 현재 메뉴 정보 조회 (부모 ID 확인) + const currentMenuQuery = ` + SELECT parent_obj_id FROM menu_info + WHERE objid = $1 + `; + const currentMenuResult = await pool.query(currentMenuQuery, [menuObjid]); + + if (currentMenuResult.rows.length === 0) { + logger.warn("메뉴를 찾을 수 없음, 자기 자신만 반환", { menuObjid }); + return [menuObjid]; + } + + const parentObjId = Number(currentMenuResult.rows[0].parent_obj_id); + + // 2. 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환 + if (parentObjId === 0) { + logger.debug("최상위 메뉴, 자기 자신만 반환", { menuObjid }); + return [menuObjid]; + } + + // 3. 형제 메뉴들 조회 (같은 부모를 가진 메뉴들) + const siblingsQuery = ` + SELECT objid FROM menu_info + WHERE parent_obj_id = $1 + ORDER BY objid + `; + const siblingsResult = await pool.query(siblingsQuery, [parentObjId]); + + const siblingObjids = siblingsResult.rows.map((row) => Number(row.objid)); + + // 4. 각 형제 메뉴(자기 자신 포함)의 자식 메뉴들도 조회 + const allObjids = [...siblingObjids]; + + for (const siblingObjid of siblingObjids) { + const childrenQuery = ` + SELECT objid FROM menu_info + WHERE parent_obj_id = $1 + ORDER BY objid + `; + const childrenResult = await pool.query(childrenQuery, [siblingObjid]); + const childObjids = childrenResult.rows.map((row) => Number(row.objid)); + allObjids.push(...childObjids); + } + + // 5. 중복 제거 및 정렬 + const uniqueObjids = Array.from(new Set(allObjids)).sort((a, b) => a - b); + + logger.debug("메뉴 스코프 조회 완료", { + menuObjid, + parentObjId, + siblingCount: siblingObjids.length, + totalCount: uniqueObjids.length + }); + + return uniqueObjids; + } catch (error: any) { + logger.error("메뉴 스코프 조회 실패", { + menuObjid, + error: error.message, + stack: error.stack + }); + // 에러 발생 시 안전하게 자기 자신만 반환 + return [menuObjid]; + } +} + +/** + * 여러 메뉴의 형제 메뉴 OBJID 합집합 조회 + * + * 여러 메뉴에 속한 모든 형제 메뉴를 중복 제거하여 반환 + * + * @param menuObjids 메뉴 OBJID 배열 + * @returns 모든 형제 메뉴 OBJID 배열 (중복 제거, 정렬됨) + * + * @example + * // 서로 다른 부모를 가진 메뉴들의 형제를 모두 조회 + * await getAllSiblingMenuObjids([201, 301]); + * // 201의 형제: [201, 202, 203] + * // 301의 형제: [301, 302] + * // 결과: [201, 202, 203, 301, 302] + */ +export async function getAllSiblingMenuObjids( + menuObjids: number[] +): Promise { + if (!menuObjids || menuObjids.length === 0) { + logger.warn("getAllSiblingMenuObjids: 빈 배열 입력"); + return []; + } + + const allSiblings = new Set(); + + for (const objid of menuObjids) { + const siblings = await getSiblingMenuObjids(objid); + siblings.forEach((s) => allSiblings.add(s)); + } + + const result = Array.from(allSiblings).sort((a, b) => a - b); + + logger.info("여러 메뉴의 형제 조회 완료", { + inputMenus: menuObjids, + resultCount: result.length, + result, + }); + + return result; +} + +/** + * 메뉴 정보 조회 + * + * @param menuObjid 메뉴 OBJID + * @returns 메뉴 정보 (없으면 null) + */ +export async function getMenuInfo(menuObjid: number): Promise { + const pool = getPool(); + + try { + const query = ` + SELECT + objid, + parent_obj_id AS "parentObjId", + menu_name_kor AS "menuNameKor", + menu_name_eng AS "menuNameEng", + menu_url AS "menuUrl", + company_code AS "companyCode" + FROM menu_info + WHERE objid = $1 + `; + const result = await pool.query(query, [menuObjid]); + + if (result.rows.length === 0) { + return null; + } + + return result.rows[0]; + } catch (error: any) { + logger.error("메뉴 정보 조회 실패", { menuObjid, error: error.message }); + return null; + } +} + diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 98230b65..368559df 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -4,6 +4,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { getSiblingMenuObjids } from "./menuService"; interface NumberingRulePart { id?: number; @@ -150,22 +151,34 @@ class NumberingRuleService { } /** - * 현재 메뉴에서 사용 가능한 규칙 목록 조회 + * 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프) + * + * 메뉴 스코프 규칙: + * - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함 + * - 우선순위: menu (형제 메뉴) > table > global */ async getAvailableRulesForMenu( companyCode: string, menuObjid?: number ): Promise { + let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 + try { - logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", { + logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { companyCode, menuObjid, }); const pool = getPool(); + // 1. 형제 메뉴 OBJID 조회 + if (menuObjid) { + siblingObjids = await getSiblingMenuObjids(menuObjid); + logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + } + // menuObjid가 없으면 global 규칙만 반환 - if (!menuObjid) { + if (!menuObjid || siblingObjids.length === 0) { let query: string; let params: any[]; @@ -261,35 +274,13 @@ class NumberingRuleService { return result.rows; } - // 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기) - const menuHierarchyQuery = ` - WITH RECURSIVE menu_path AS ( - SELECT objid, objid_parent, menu_level - FROM menu_info - WHERE objid = $1 - - UNION ALL - - SELECT mi.objid, mi.objid_parent, mi.menu_level - FROM menu_info mi - INNER JOIN menu_path mp ON mi.objid = mp.objid_parent - ) - SELECT objid, menu_level - FROM menu_path - WHERE menu_level = 2 - LIMIT 1 - `; - - const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]); - const level2MenuObjid = - hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null; - - // 사용 가능한 규칙 조회 (멀티테넌시 적용) + // 2. 메뉴 스코프: 형제 메뉴의 채번 규칙 조회 + // 우선순위: menu (형제 메뉴) > table > global let query: string; let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 규칙 조회 + // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) query = ` SELECT rule_id AS "ruleId", @@ -309,12 +300,22 @@ class NumberingRuleService { FROM numbering_rules WHERE scope_type = 'global' - OR (scope_type = 'menu' AND menu_objid = $1) - ORDER BY scope_type DESC, created_at DESC + OR scope_type = 'table' + OR (scope_type = 'menu' AND menu_objid = ANY($1)) + OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링 + OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함 + ORDER BY + CASE + WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 + WHEN scope_type = 'table' THEN 2 + WHEN scope_type = 'global' THEN 3 + END, + created_at DESC `; - params = [level2MenuObjid]; + params = [siblingObjids]; + logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 + // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함) query = ` SELECT rule_id AS "ruleId", @@ -335,58 +336,93 @@ class NumberingRuleService { WHERE company_code = $1 AND ( scope_type = 'global' - OR (scope_type = 'menu' AND menu_objid = $2) + OR scope_type = 'table' + OR (scope_type = 'menu' AND menu_objid = ANY($2)) + OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링 + OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함 ) - ORDER BY scope_type DESC, created_at DESC + ORDER BY + CASE + WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1 + WHEN scope_type = 'table' THEN 2 + WHEN scope_type = 'global' THEN 3 + END, + created_at DESC `; - params = [companyCode, level2MenuObjid]; + params = [companyCode, siblingObjids]; + logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids }); } + logger.info("🔍 채번 규칙 쿼리 실행", { + queryPreview: query.substring(0, 200), + paramsTypes: params.map(p => typeof p), + paramsValues: params, + }); + const result = await pool.query(query, params); + logger.debug("채번 규칙 쿼리 성공", { ruleCount: result.rows.length }); + // 파트 정보 추가 for (const rule of result.rows) { - let partsQuery: string; - let partsParams: any[]; - - if (companyCode === "*") { - partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 - ORDER BY part_order - `; - partsParams = [rule.ruleId]; - } else { - partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 AND company_code = $2 - ORDER BY part_order - `; - partsParams = [rule.ruleId, companyCode]; - } + try { + let partsQuery: string; + let partsParams: any[]; + + if (companyCode === "*") { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + ORDER BY part_order + `; + partsParams = [rule.ruleId]; + } else { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + partsParams = [rule.ruleId, companyCode]; + } - const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + const partsResult = await pool.query(partsQuery, partsParams); + rule.parts = partsResult.rows; + + logger.info("✅ 규칙 파트 조회 성공", { + ruleId: rule.ruleId, + ruleName: rule.ruleName, + partsCount: partsResult.rows.length, + }); + } catch (partError: any) { + logger.error("❌ 규칙 파트 조회 실패", { + ruleId: rule.ruleId, + ruleName: rule.ruleName, + error: partError.message, + errorCode: partError.code, + errorStack: partError.stack, + }); + throw partError; + } } logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, - level2MenuObjid, + siblingCount: siblingObjids.length, count: result.rowCount, }); @@ -394,8 +430,11 @@ class NumberingRuleService { } catch (error: any) { logger.error("메뉴별 채번 규칙 조회 실패", { error: error.message, + errorCode: error.code, + errorStack: error.stack, companyCode, menuObjid, + siblingObjids: siblingObjids || [], }); throw error; } diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index f3c3d133..85081cd3 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1,5 +1,5 @@ // ✅ Prisma → Raw Query 전환 (Phase 2.1) -import { query, transaction } from "../database/db"; +import { query, queryOne, transaction } from "../database/db"; import { ScreenDefinition, CreateScreenRequest, @@ -1547,6 +1547,39 @@ export class ScreenManagementService { return screens.map((screen) => this.mapToScreenDefinition(screen)); } + /** + * 화면에 할당된 메뉴 조회 (첫 번째 할당만 반환) + * 화면 편집기에서 menuObjid를 가져오기 위해 사용 + */ + async getMenuByScreen( + screenId: number, + companyCode: string + ): Promise<{ menuObjid: number; menuName?: string } | null> { + const result = await queryOne<{ + menu_objid: string; + menu_name_kor?: string; + }>( + `SELECT sma.menu_objid, mi.menu_name_kor + FROM screen_menu_assignments sma + LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid + WHERE sma.screen_id = $1 + AND sma.company_code = $2 + AND sma.is_active = 'Y' + ORDER BY sma.created_date ASC + LIMIT 1`, + [screenId, companyCode] + ); + + if (!result) { + return null; + } + + return { + menuObjid: parseInt(result.menu_objid), + menuName: result.menu_name_kor, + }; + } + /** * 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료) */ diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 8a20aac1..e60d6cd2 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1,5 +1,6 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { getSiblingMenuObjids } from "./menuService"; import { TableCategoryValue, CategoryColumn, @@ -62,7 +63,9 @@ class TableCategoryValueService { logger.info("회사별 카테고리 컬럼 조회", { companyCode }); } - const result = await pool.query(query, [tableName, companyCode]); + // 쿼리 파라미터는 company_code에 따라 다름 + const params = companyCode === "*" ? [tableName] : [tableName, companyCode]; + const result = await pool.query(query, params); logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, { tableName, @@ -77,30 +80,44 @@ class TableCategoryValueService { } /** - * 특정 컬럼의 카테고리 값 목록 조회 (테이블 스코프) + * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프) + * + * 메뉴 스코프 규칙: + * - menuObjid가 제공되면 해당 메뉴와 형제 메뉴의 카테고리 값을 조회 + * - menuObjid가 없으면 테이블 스코프로 동작 (하위 호환성) */ async getCategoryValues( tableName: string, columnName: string, companyCode: string, - includeInactive: boolean = false + includeInactive: boolean = false, + menuObjid?: number ): Promise { try { - logger.info("카테고리 값 목록 조회", { + logger.info("카테고리 값 목록 조회 (메뉴 스코프)", { tableName, columnName, companyCode, includeInactive, + menuObjid, }); const pool = getPool(); - // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 + // 1. 메뉴 스코프: 형제 메뉴 OBJID 조회 + let siblingObjids: number[] = []; + if (menuObjid) { + siblingObjids = await getSiblingMenuObjids(menuObjid); + logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + } + + // 2. 카테고리 값 조회 (형제 메뉴 포함) let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 조회 + // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 query = ` SELECT value_id AS "valueId", @@ -117,6 +134,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", + menu_objid AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", @@ -129,6 +147,7 @@ class TableCategoryValueService { logger.info("최고 관리자 카테고리 값 조회"); } else { // 일반 회사: 자신의 카테고리 값만 조회 + // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 query = ` SELECT value_id AS "valueId", @@ -145,6 +164,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", + menu_objid AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", @@ -173,6 +193,8 @@ class TableCategoryValueService { tableName, columnName, companyCode, + menuObjid, + scopeType: menuObjid ? "menu" : "table", }); return values; @@ -183,17 +205,31 @@ class TableCategoryValueService { } /** - * 카테고리 값 추가 + * 카테고리 값 추가 (메뉴 스코프) + * + * @param value 카테고리 값 정보 + * @param companyCode 회사 코드 + * @param userId 생성자 ID + * @param menuObjid 메뉴 OBJID (필수) */ async addCategoryValue( value: TableCategoryValue, companyCode: string, - userId: string + userId: string, + menuObjid: number ): Promise { const pool = getPool(); try { - // 중복 코드 체크 (멀티테넌시 적용) + logger.info("카테고리 값 추가 (메뉴 스코프)", { + tableName: value.tableName, + columnName: value.columnName, + valueCode: value.valueCode, + menuObjid, + companyCode, + }); + + // 중복 코드 체크 (멀티테넌시 + 메뉴 스코프) let duplicateQuery: string; let duplicateParams: any[]; @@ -205,8 +241,9 @@ class TableCategoryValueService { WHERE table_name = $1 AND column_name = $2 AND value_code = $3 + AND menu_objid = $4 `; - duplicateParams = [value.tableName, value.columnName, value.valueCode]; + duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid]; } else { // 일반 회사: 자신의 회사에서만 중복 체크 duplicateQuery = ` @@ -215,9 +252,10 @@ class TableCategoryValueService { WHERE table_name = $1 AND column_name = $2 AND value_code = $3 - AND company_code = $4 + AND menu_objid = $4 + AND company_code = $5 `; - duplicateParams = [value.tableName, value.columnName, value.valueCode, companyCode]; + duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode]; } const duplicateResult = await pool.query(duplicateQuery, duplicateParams); @@ -230,8 +268,8 @@ class TableCategoryValueService { 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, company_code, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + is_active, is_default, company_code, menu_objid, 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", @@ -247,6 +285,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", + menu_objid AS "menuObjid", created_at AS "createdAt", created_by AS "createdBy" `; @@ -265,6 +304,7 @@ class TableCategoryValueService { value.isActive !== false, value.isDefault || false, companyCode, + menuObjid, // ← 메뉴 OBJID 저장 userId, ]); @@ -272,6 +312,7 @@ class TableCategoryValueService { valueId: result.rows[0].valueId, tableName: value.tableName, columnName: value.columnName, + menuObjid, }); return result.rows[0]; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index b45a0424..ac8b62fd 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1069,12 +1069,28 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🔧 {value, operator} 형태의 필터 객체 처리 + let actualValue = value; + let operator = "contains"; // 기본값 + + if (typeof value === "object" && value !== null && "value" in value) { + actualValue = value.value; + operator = value.operator || "contains"; + + logger.info("🔍 필터 객체 처리:", { + columnName, + originalValue: value, + actualValue, + operator, + }); + } + // "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음 if ( - value === "__ALL__" || - value === "" || - value === null || - value === undefined + actualValue === "__ALL__" || + actualValue === "" || + actualValue === null || + actualValue === undefined ) { return null; } @@ -1083,12 +1099,22 @@ export class TableManagementService { const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); if (!columnInfo) { - // 컬럼 정보가 없으면 기본 문자열 검색 - return { - whereClause: `${columnName}::text ILIKE $${paramIndex}`, - values: [`%${value}%`], - paramCount: 1, - }; + // 컬럼 정보가 없으면 operator에 따른 기본 검색 + switch (operator) { + case "equals": + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [actualValue], + paramCount: 1, + }; + case "contains": + default: + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${actualValue}%`], + paramCount: 1, + }; + } } const webType = columnInfo.webType; @@ -1097,17 +1123,17 @@ export class TableManagementService { switch (webType) { case "date": case "datetime": - return this.buildDateRangeCondition(columnName, value, paramIndex); + return this.buildDateRangeCondition(columnName, actualValue, paramIndex); case "number": case "decimal": - return this.buildNumberRangeCondition(columnName, value, paramIndex); + return this.buildNumberRangeCondition(columnName, actualValue, paramIndex); case "code": return await this.buildCodeSearchCondition( tableName, columnName, - value, + actualValue, paramIndex ); @@ -1115,15 +1141,15 @@ export class TableManagementService { return await this.buildEntitySearchCondition( tableName, columnName, - value, + actualValue, paramIndex ); default: - // 기본 문자열 검색 + // 기본 문자열 검색 (actualValue 사용) return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, - values: [`%${value}%`], + values: [`%${actualValue}%`], paramCount: 1, }; } @@ -1133,9 +1159,14 @@ export class TableManagementService { error ); // 오류 시 기본 검색으로 폴백 + let fallbackValue = value; + if (typeof value === "object" && value !== null && "value" in value) { + fallbackValue = value.value; + } + return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, - values: [`%${value}%`], + values: [`%${fallbackValue}%`], paramCount: 1, }; } @@ -1494,6 +1525,7 @@ export class TableManagementService { search?: Record; sortBy?: string; sortOrder?: string; + companyCode?: string; } ): Promise<{ data: any[]; @@ -1503,7 +1535,7 @@ export class TableManagementService { totalPages: number; }> { try { - const { page, size, search = {}, sortBy, sortOrder = "asc" } = options; + const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options; const offset = (page - 1) * size; logger.info(`테이블 데이터 조회: ${tableName}`, options); @@ -1517,6 +1549,14 @@ export class TableManagementService { let searchValues: any[] = []; let paramIndex = 1; + // 멀티테넌시 필터 추가 (company_code) + if (companyCode) { + whereConditions.push(`company_code = $${paramIndex}`); + searchValues.push(companyCode); + paramIndex++; + logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`); + } + if (search && Object.keys(search).length > 0) { for (const [column, value] of Object.entries(search)) { if (value !== null && value !== undefined && value !== "") { @@ -2048,6 +2088,7 @@ export class TableManagementService { sortBy?: string; sortOrder?: string; enableEntityJoin?: boolean; + companyCode?: string; // 멀티테넌시 필터용 additionalJoinColumns?: Array<{ sourceTable: string; sourceColumn: string; @@ -2213,11 +2254,20 @@ export class TableManagementService { const selectColumns = columns.data.map((col: any) => col.column_name); // WHERE 절 구성 - const whereClause = await this.buildWhereClause( + let whereClause = await this.buildWhereClause( tableName, options.search ); + // 멀티테넌시 필터 추가 (company_code) + if (options.companyCode) { + const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`; + whereClause = whereClause + ? `${whereClause} AND ${companyFilter}` + : companyFilter; + logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`); + } + // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` @@ -2343,6 +2393,7 @@ export class TableManagementService { search?: Record; sortBy?: string; sortOrder?: string; + companyCode?: string; }, startTime: number ): Promise { @@ -2530,11 +2581,11 @@ export class TableManagementService { ); } - basicResult = await this.getTableData(tableName, fallbackOptions); + basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode }); } } else { // Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용 - basicResult = await this.getTableData(tableName, options); + basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode }); } // Entity 값들을 캐시에서 룩업하여 변환 @@ -2807,10 +2858,14 @@ export class TableManagementService { } // 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업 else { + // whereClause에서 company_code 추출 (멀티테넌시 필터) + const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/); + const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined; + return await this.executeCachedLookup( tableName, cacheableJoins, - { page: Math.floor(offset / limit) + 1, size: limit, search: {} }, + { page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode }, startTime ); } @@ -2831,6 +2886,13 @@ export class TableManagementService { const dbJoins: EntityJoinConfig[] = []; for (const config of joinConfigs) { + // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 + if (config.referenceTable === 'table_column_category_values') { + dbJoins.push(config); + console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); + continue; + } + // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, diff --git a/docs/테이블_검색필터_컴포넌트_분리_계획서.md b/docs/테이블_검색필터_컴포넌트_분리_계획서.md new file mode 100644 index 00000000..28bd54b8 --- /dev/null +++ b/docs/테이블_검색필터_컴포넌트_분리_계획서.md @@ -0,0 +1,2016 @@ +# 테이블 검색 필터 컴포넌트 분리 및 통합 계획서 + +## 📋 목차 + +1. [현황 분석](#1-현황-분석) +2. [목표 및 요구사항](#2-목표-및-요구사항) +3. [아키텍처 설계](#3-아키텍처-설계) +4. [구현 계획](#4-구현-계획) +5. [파일 구조](#5-파일-구조) +6. [통합 시나리오](#6-통합-시나리오) +7. [주요 기능 및 개선 사항](#7-주요-기능-및-개선-사항) +8. [예상 장점](#8-예상-장점) +9. [구현 우선순위](#9-구현-우선순위) +10. [체크리스트](#10-체크리스트) + +--- + +## 1. 현황 분석 + +### 1.1 현재 구조 + +- **테이블 리스트 컴포넌트**에 테이블 옵션이 내장되어 있음 +- 각 테이블 컴포넌트마다 개별적으로 옵션 기능 구현 +- 코드 중복 및 유지보수 어려움 + +### 1.2 현재 제공 기능 + +#### 테이블 옵션 + +- 컬럼 표시/숨김 설정 +- 컬럼 순서 변경 (드래그앤드롭) +- 컬럼 너비 조정 +- 고정 컬럼 설정 + +#### 필터 설정 + +- 컬럼별 검색 필터 적용 +- 다중 필터 조건 지원 +- 연산자 선택 (같음, 포함, 시작, 끝) + +#### 그룹 설정 + +- 컬럼별 데이터 그룹화 +- 다중 그룹 레벨 지원 +- 그룹별 집계 표시 + +### 1.3 적용 대상 컴포넌트 + +1. **TableList**: 기본 테이블 리스트 컴포넌트 +2. **SplitPanel**: 좌/우 분할 테이블 (마스터-디테일 관계) +3. **FlowWidget**: 플로우 스텝별 데이터 테이블 + +--- + +## 2. 목표 및 요구사항 + +### 2.1 핵심 목표 + +1. 테이블 옵션 기능을 **재사용 가능한 공통 컴포넌트**로 분리 +2. 화면에 있는 테이블 컴포넌트를 **자동 감지**하여 검색 가능 +3. 각 컴포넌트의 테이블 데이터와 **독립적으로 연동** +4. 기존 기능을 유지하면서 확장 가능한 구조 구축 + +### 2.2 기능 요구사항 + +#### 자동 감지 + +- 화면 로드 시 테이블 컴포넌트 자동 식별 +- 컴포넌트 추가/제거 시 동적 반영 +- 테이블 ID 기반 고유 식별 + +#### 다중 테이블 지원 + +- 한 화면에 여러 테이블이 있을 경우 선택 가능 +- 테이블 간 독립적인 설정 관리 +- 선택된 테이블에만 옵션 적용 + +#### 실시간 적용 + +- 필터/그룹 설정 시 즉시 테이블 업데이트 +- 불필요한 전체 화면 리렌더링 방지 +- 최적화된 데이터 조회 + +#### 상태 독립성 + +- 각 테이블의 설정이 독립적으로 유지 +- 한 테이블의 설정이 다른 테이블에 영향 없음 +- 화면 전환 시 설정 보존 (선택사항) + +### 2.3 비기능 요구사항 + +- **성능**: 100개 이상의 컬럼도 부드럽게 처리 +- **접근성**: 키보드 네비게이션 지원 +- **반응형**: 모바일/태블릿 대응 +- **확장성**: 새로운 테이블 타입 추가 용이 + +--- + +## 3. 아키텍처 설계 + +### 3.1 컴포넌트 구조 + +``` +TableOptionsToolbar (신규 - 메인 툴바) +├── TableSelector (다중 테이블 선택 드롭다운) +├── ColumnVisibilityButton (테이블 옵션 버튼) +├── FilterButton (필터 설정 버튼) +└── GroupingButton (그룹 설정 버튼) + +패널 컴포넌트들 (Dialog 형태) +├── ColumnVisibilityPanel (컬럼 표시/숨김 설정) +├── FilterPanel (검색 필터 설정) +└── GroupingPanel (그룹화 설정) + +Context & Provider +├── TableOptionsContext (테이블 등록 및 관리) +└── TableOptionsProvider (전역 상태 관리) + +화면 컴포넌트들 (기존 수정) +├── TableList → TableOptionsContext 연동 +├── SplitPanel → 좌/우 각각 등록 +└── FlowWidget → 스텝별 등록 +``` + +### 3.2 데이터 흐름 + +```mermaid +graph TD + A[화면 컴포넌트] --> B[registerTable 호출] + B --> C[TableOptionsContext에 등록] + C --> D[TableOptionsToolbar에서 목록 조회] + D --> E[사용자가 테이블 선택] + E --> F[옵션 버튼 클릭] + F --> G[패널 열림] + G --> H[설정 변경] + H --> I[선택된 테이블의 콜백 호출] + I --> J[테이블 컴포넌트 업데이트] + J --> K[데이터 재조회/재렌더링] +``` + +### 3.3 상태 관리 구조 + +```typescript +// Context에서 관리하는 전역 상태 +{ + registeredTables: Map { + "table-list-123": { + tableId: "table-list-123", + label: "품목 관리", + tableName: "item_info", + columns: [...], + onFilterChange: (filters) => {}, + onGroupChange: (groups) => {}, + onColumnVisibilityChange: (columns) => {} + }, + "split-panel-left-456": { + tableId: "split-panel-left-456", + label: "분할 패널 (좌측)", + tableName: "category_values", + columns: [...], + ... + } + } +} + +// 각 테이블 컴포넌트가 관리하는 로컬 상태 +{ + filters: [ + { columnName: "item_name", operator: "contains", value: "나사" } + ], + grouping: ["category_id", "material"], + columnVisibility: [ + { columnName: "item_name", visible: true, width: 200, order: 1 }, + { columnName: "status", visible: false, width: 100, order: 2 } + ] +} +``` + +--- + +## 4. 구현 계획 + +### Phase 1: Context 및 Provider 구현 + +#### 4.1.1 타입 정의 + +**파일**: `types/table-options.ts` + +```typescript +/** + * 테이블 필터 조건 + */ +export interface TableFilter { + columnName: string; + operator: + | "equals" + | "contains" + | "startsWith" + | "endsWith" + | "gt" + | "lt" + | "gte" + | "lte" + | "notEquals"; + value: string | number | boolean; +} + +/** + * 컬럼 표시 설정 + */ +export interface ColumnVisibility { + columnName: string; + visible: boolean; + width?: number; + order?: number; + fixed?: boolean; // 좌측 고정 여부 +} + +/** + * 테이블 컬럼 정보 + */ +export interface TableColumn { + columnName: string; + columnLabel: string; + inputType: string; + visible: boolean; + width: number; + sortable?: boolean; + filterable?: boolean; +} + +/** + * 테이블 등록 정보 + */ +export interface TableRegistration { + tableId: string; // 고유 ID (예: "table-list-123") + label: string; // 사용자에게 보이는 이름 (예: "품목 관리") + tableName: string; // 실제 DB 테이블명 (예: "item_info") + columns: TableColumn[]; + + // 콜백 함수들 + onFilterChange: (filters: TableFilter[]) => void; + onGroupChange: (groups: string[]) => void; + onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; +} + +/** + * Context 값 타입 + */ +export interface TableOptionsContextValue { + registeredTables: Map; + registerTable: (registration: TableRegistration) => void; + unregisterTable: (tableId: string) => void; + getTable: (tableId: string) => TableRegistration | undefined; + selectedTableId: string | null; + setSelectedTableId: (tableId: string | null) => void; +} +``` + +#### 4.1.2 Context 생성 + +**파일**: `contexts/TableOptionsContext.tsx` + +```typescript +import React, { + createContext, + useContext, + useState, + useCallback, + ReactNode, +} from "react"; +import { + TableRegistration, + TableOptionsContextValue, +} from "@/types/table-options"; + +const TableOptionsContext = createContext( + undefined +); + +export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [registeredTables, setRegisteredTables] = useState< + Map + >(new Map()); + const [selectedTableId, setSelectedTableId] = useState(null); + + /** + * 테이블 등록 + */ + const registerTable = useCallback((registration: TableRegistration) => { + setRegisteredTables((prev) => { + const newMap = new Map(prev); + newMap.set(registration.tableId, registration); + + // 첫 번째 테이블이면 자동 선택 + if (newMap.size === 1) { + setSelectedTableId(registration.tableId); + } + + return newMap; + }); + + console.log( + `[TableOptions] 테이블 등록: ${registration.label} (${registration.tableId})` + ); + }, []); + + /** + * 테이블 등록 해제 + */ + const unregisterTable = useCallback( + (tableId: string) => { + setRegisteredTables((prev) => { + const newMap = new Map(prev); + const removed = newMap.delete(tableId); + + if (removed) { + console.log(`[TableOptions] 테이블 해제: ${tableId}`); + + // 선택된 테이블이 제거되면 첫 번째 테이블 선택 + if (selectedTableId === tableId) { + const firstTableId = newMap.keys().next().value; + setSelectedTableId(firstTableId || null); + } + } + + return newMap; + }); + }, + [selectedTableId] + ); + + /** + * 특정 테이블 조회 + */ + const getTable = useCallback( + (tableId: string) => { + return registeredTables.get(tableId); + }, + [registeredTables] + ); + + return ( + + {children} + + ); +}; + +/** + * Context Hook + */ +export const useTableOptions = () => { + const context = useContext(TableOptionsContext); + if (!context) { + throw new Error("useTableOptions must be used within TableOptionsProvider"); + } + return context; +}; +``` + +--- + +### Phase 2: TableOptionsToolbar 컴포넌트 구현 + +**파일**: `components/screen/table-options/TableOptionsToolbar.tsx` + +```typescript +import React, { useState } from "react"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Settings, Filter, Layers } from "lucide-react"; +import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel"; +import { FilterPanel } from "./FilterPanel"; +import { GroupingPanel } from "./GroupingPanel"; + +export const TableOptionsToolbar: React.FC = () => { + const { registeredTables, selectedTableId, setSelectedTableId } = + useTableOptions(); + + const [columnPanelOpen, setColumnPanelOpen] = useState(false); + const [filterPanelOpen, setFilterPanelOpen] = useState(false); + const [groupPanelOpen, setGroupPanelOpen] = useState(false); + + const tableList = Array.from(registeredTables.values()); + const selectedTable = selectedTableId + ? registeredTables.get(selectedTableId) + : null; + + // 테이블이 없으면 표시하지 않음 + if (tableList.length === 0) { + return null; + } + + return ( +
+ {/* 테이블 선택 (2개 이상일 때만 표시) */} + {tableList.length > 1 && ( + + )} + + {/* 테이블이 1개일 때는 이름만 표시 */} + {tableList.length === 1 && ( +
+ {tableList[0].label} +
+ )} + + {/* 컬럼 수 표시 */} +
+ 전체 {selectedTable?.columns.length || 0}개 +
+ +
+ + {/* 옵션 버튼들 */} + + + + + + + {/* 패널들 */} + {selectedTableId && ( + <> + + + + + )} +
+ ); +}; +``` + +--- + +### Phase 3: 패널 컴포넌트 구현 + +#### 4.3.1 ColumnVisibilityPanel + +**파일**: `components/screen/table-options/ColumnVisibilityPanel.tsx` + +```typescript +import React, { useState, useEffect } from "react"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { GripVertical, Eye, EyeOff } from "lucide-react"; +import { ColumnVisibility } from "@/types/table-options"; + +interface Props { + tableId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ColumnVisibilityPanel: React.FC = ({ + tableId, + open, + onOpenChange, +}) => { + const { getTable } = useTableOptions(); + const table = getTable(tableId); + + const [localColumns, setLocalColumns] = useState([]); + + // 테이블 정보 로드 + useEffect(() => { + if (table) { + setLocalColumns( + table.columns.map((col) => ({ + columnName: col.columnName, + visible: col.visible, + width: col.width, + order: 0, + })) + ); + } + }, [table]); + + const handleVisibilityChange = (columnName: string, visible: boolean) => { + setLocalColumns((prev) => + prev.map((col) => + col.columnName === columnName ? { ...col, visible } : col + ) + ); + }; + + const handleWidthChange = (columnName: string, width: number) => { + setLocalColumns((prev) => + prev.map((col) => + col.columnName === columnName ? { ...col, width } : col + ) + ); + }; + + const handleApply = () => { + table?.onColumnVisibilityChange(localColumns); + onOpenChange(false); + }; + + const handleReset = () => { + if (table) { + setLocalColumns( + table.columns.map((col) => ({ + columnName: col.columnName, + visible: true, + width: 150, + order: 0, + })) + ); + } + }; + + const visibleCount = localColumns.filter((col) => col.visible).length; + + return ( + + + + + 테이블 옵션 + + + 컬럼 표시/숨기기, 순서 변경, 너비 등을 설정할 수 있습니다. 모든 + 테두리를 드래그하여 크기를 조정할 수 있습니다. + + + +
+ {/* 상태 표시 */} +
+
+ {visibleCount}/{localColumns.length}개 컬럼 표시 중 +
+ +
+ + {/* 컬럼 리스트 */} + +
+ {localColumns.map((col, index) => { + const columnMeta = table?.columns.find( + (c) => c.columnName === col.columnName + ); + return ( +
+ {/* 드래그 핸들 */} + + + {/* 체크박스 */} + + handleVisibilityChange( + col.columnName, + checked as boolean + ) + } + /> + + {/* 가시성 아이콘 */} + {col.visible ? ( + + ) : ( + + )} + + {/* 컬럼명 */} +
+
+ {columnMeta?.columnLabel} +
+
+ {col.columnName} +
+
+ + {/* 너비 설정 */} +
+ + + handleWidthChange( + col.columnName, + parseInt(e.target.value) || 150 + ) + } + className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm" + min={50} + max={500} + /> +
+
+ ); + })} +
+
+
+ + + + + +
+
+ ); +}; +``` + +#### 4.3.2 FilterPanel + +**파일**: `components/screen/table-options/FilterPanel.tsx` + +```typescript +import React, { useState, useEffect } from "react"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Plus, X } from "lucide-react"; +import { TableFilter } from "@/types/table-options"; + +interface Props { + tableId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const FilterPanel: React.FC = ({ + tableId, + open, + onOpenChange, +}) => { + const { getTable } = useTableOptions(); + const table = getTable(tableId); + + const [activeFilters, setActiveFilters] = useState([]); + + const addFilter = () => { + setActiveFilters([ + ...activeFilters, + { columnName: "", operator: "contains", value: "" }, + ]); + }; + + const removeFilter = (index: number) => { + setActiveFilters(activeFilters.filter((_, i) => i !== index)); + }; + + const updateFilter = ( + index: number, + field: keyof TableFilter, + value: any + ) => { + setActiveFilters( + activeFilters.map((filter, i) => + i === index ? { ...filter, [field]: value } : filter + ) + ); + }; + + const applyFilters = () => { + // 빈 필터 제거 + const validFilters = activeFilters.filter( + (f) => f.columnName && f.value !== "" + ); + table?.onFilterChange(validFilters); + onOpenChange(false); + }; + + const clearFilters = () => { + setActiveFilters([]); + table?.onFilterChange([]); + }; + + const operatorLabels: Record = { + equals: "같음", + contains: "포함", + startsWith: "시작", + endsWith: "끝", + gt: "보다 큼", + lt: "보다 작음", + gte: "이상", + lte: "이하", + notEquals: "같지 않음", + }; + + return ( + + + + + 검색 필터 설정 + + + 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 + 표시됩니다. + + + +
+ {/* 전체 선택/해제 */} +
+
+ 총 {activeFilters.length}개의 검색 필터가 표시됩니다 +
+ +
+ + {/* 필터 리스트 */} + +
+ {activeFilters.map((filter, index) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 값 입력 */} + + updateFilter(index, "value", e.target.value) + } + placeholder="값 입력" + className="h-8 flex-1 text-xs sm:h-9 sm:text-sm" + /> + + {/* 삭제 버튼 */} + +
+ ))} +
+
+ + {/* 필터 추가 버튼 */} + +
+ + + + + +
+
+ ); +}; +``` + +#### 4.3.3 GroupingPanel + +**파일**: `components/screen/table-options/GroupingPanel.tsx` + +```typescript +import React, { useState } from "react"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ArrowRight } from "lucide-react"; + +interface Props { + tableId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const GroupingPanel: React.FC = ({ + tableId, + open, + onOpenChange, +}) => { + const { getTable } = useTableOptions(); + const table = getTable(tableId); + + const [selectedColumns, setSelectedColumns] = useState([]); + + const toggleColumn = (columnName: string) => { + if (selectedColumns.includes(columnName)) { + setSelectedColumns(selectedColumns.filter((c) => c !== columnName)); + } else { + setSelectedColumns([...selectedColumns, columnName]); + } + }; + + const applyGrouping = () => { + table?.onGroupChange(selectedColumns); + onOpenChange(false); + }; + + const clearGrouping = () => { + setSelectedColumns([]); + table?.onGroupChange([]); + }; + + return ( + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요 + + + +
+ {/* 상태 표시 */} +
+
+ {selectedColumns.length}개 컬럼으로 그룹화 +
+ +
+ + {/* 컬럼 리스트 */} + +
+ {table?.columns.map((col, index) => { + const isSelected = selectedColumns.includes(col.columnName); + const order = selectedColumns.indexOf(col.columnName) + 1; + + return ( +
+ toggleColumn(col.columnName)} + /> + +
+
+ {col.columnLabel} +
+
+ {col.columnName} +
+
+ + {isSelected && ( +
+ {order}번째 +
+ )} +
+ ); + })} +
+
+ + {/* 그룹 순서 미리보기 */} + {selectedColumns.length > 0 && ( +
+
+ 그룹화 순서 +
+
+ {selectedColumns.map((colName, index) => { + const col = table?.columns.find( + (c) => c.columnName === colName + ); + return ( + +
+ {col?.columnLabel} +
+ {index < selectedColumns.length - 1 && ( + + )} +
+ ); + })} +
+
+ )} +
+ + + + + +
+
+ ); +}; +``` + +--- + +### Phase 4: 기존 테이블 컴포넌트 통합 + +#### 4.4.1 TableList 컴포넌트 수정 + +**파일**: `components/screen/interactive/TableList.tsx` + +```typescript +import { useEffect, useState, useCallback } from "react"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { TableFilter, ColumnVisibility } from "@/types/table-options"; + +export const TableList: React.FC = ({ component }) => { + const { registerTable, unregisterTable } = useTableOptions(); + + // 로컬 상태 + const [filters, setFilters] = useState([]); + const [grouping, setGrouping] = useState([]); + const [columnVisibility, setColumnVisibility] = useState( + [] + ); + const [data, setData] = useState([]); + + const tableId = `table-list-${component.id}`; + + // 테이블 등록 + useEffect(() => { + registerTable({ + tableId, + label: component.title || "테이블", + tableName: component.tableName, + columns: component.columns.map((col) => ({ + columnName: col.field, + columnLabel: col.label, + inputType: col.inputType, + visible: col.visible ?? true, + width: col.width || 150, + sortable: col.sortable, + filterable: col.filterable, + })), + onFilterChange: setFilters, + onGroupChange: setGrouping, + onColumnVisibilityChange: setColumnVisibility, + }); + + return () => unregisterTable(tableId); + }, [component.id, component.tableName, component.columns]); + + // 데이터 조회 + const fetchData = useCallback(async () => { + try { + const params = { + tableName: component.tableName, + filters: JSON.stringify(filters), + groupBy: grouping.join(","), + }; + + const response = await apiClient.get("/api/table/data", { params }); + + if (response.data.success) { + setData(response.data.data); + } + } catch (error) { + console.error("데이터 조회 실패:", error); + } + }, [component.tableName, filters, grouping]); + + // 필터/그룹 변경 시 데이터 재조회 + useEffect(() => { + fetchData(); + }, [fetchData]); + + // 표시할 컬럼 필터링 + const visibleColumns = component.columns.filter((col) => { + const visibility = columnVisibility.find((v) => v.columnName === col.field); + return visibility ? visibility.visible : col.visible !== false; + }); + + return ( +
+ {/* 기존 테이블 UI */} +
+ + + + {visibleColumns.map((col) => { + const visibility = columnVisibility.find( + (v) => v.columnName === col.field + ); + const width = visibility?.width || col.width || 150; + + return ( + + ); + })} + + + + {data.map((row, rowIndex) => ( + + {visibleColumns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
{row[col.field]}
+
+
+ ); +}; +``` + +#### 4.4.2 SplitPanel 컴포넌트 수정 + +**파일**: `components/screen/interactive/SplitPanel.tsx` + +```typescript +export const SplitPanel: React.FC = ({ component }) => { + const { registerTable, unregisterTable } = useTableOptions(); + + // 좌측 테이블 상태 + const [leftFilters, setLeftFilters] = useState([]); + const [leftGrouping, setLeftGrouping] = useState([]); + const [leftColumnVisibility, setLeftColumnVisibility] = useState< + ColumnVisibility[] + >([]); + + // 우측 테이블 상태 + const [rightFilters, setRightFilters] = useState([]); + const [rightGrouping, setRightGrouping] = useState([]); + const [rightColumnVisibility, setRightColumnVisibility] = useState< + ColumnVisibility[] + >([]); + + const leftTableId = `split-panel-left-${component.id}`; + const rightTableId = `split-panel-right-${component.id}`; + + // 좌측 테이블 등록 + useEffect(() => { + registerTable({ + tableId: leftTableId, + label: `${component.title || "분할 패널"} (좌측)`, + tableName: component.leftPanel.tableName, + columns: component.leftPanel.columns.map((col) => ({ + columnName: col.field, + columnLabel: col.label, + inputType: col.inputType, + visible: col.visible ?? true, + width: col.width || 150, + })), + onFilterChange: setLeftFilters, + onGroupChange: setLeftGrouping, + onColumnVisibilityChange: setLeftColumnVisibility, + }); + + return () => unregisterTable(leftTableId); + }, [component.leftPanel]); + + // 우측 테이블 등록 + useEffect(() => { + registerTable({ + tableId: rightTableId, + label: `${component.title || "분할 패널"} (우측)`, + tableName: component.rightPanel.tableName, + columns: component.rightPanel.columns.map((col) => ({ + columnName: col.field, + columnLabel: col.label, + inputType: col.inputType, + visible: col.visible ?? true, + width: col.width || 150, + })), + onFilterChange: setRightFilters, + onGroupChange: setRightGrouping, + onColumnVisibilityChange: setRightColumnVisibility, + }); + + return () => unregisterTable(rightTableId); + }, [component.rightPanel]); + + return ( +
+ {/* 좌측 테이블 */} +
+ +
+ + {/* 우측 테이블 */} +
+ +
+
+ ); +}; +``` + +#### 4.4.3 FlowWidget 컴포넌트 수정 + +**파일**: `components/screen/interactive/FlowWidget.tsx` + +```typescript +export const FlowWidget: React.FC = ({ component }) => { + const { registerTable, unregisterTable } = useTableOptions(); + + const [selectedStep, setSelectedStep] = useState(null); + const [filters, setFilters] = useState([]); + const [grouping, setGrouping] = useState([]); + const [columnVisibility, setColumnVisibility] = useState( + [] + ); + + const tableId = selectedStep + ? `flow-widget-${component.id}-step-${selectedStep.id}` + : null; + + // 선택된 스텝의 테이블 등록 + useEffect(() => { + if (!selectedStep || !tableId) return; + + registerTable({ + tableId, + label: `${selectedStep.name} 데이터`, + tableName: component.tableName, + columns: component.displayColumns.map((col) => ({ + columnName: col.field, + columnLabel: col.label, + inputType: col.inputType, + visible: col.visible ?? true, + width: col.width || 150, + })), + onFilterChange: setFilters, + onGroupChange: setGrouping, + onColumnVisibilityChange: setColumnVisibility, + }); + + return () => unregisterTable(tableId); + }, [selectedStep, component.displayColumns]); + + return ( +
+ {/* 플로우 스텝 선택 UI */} +
{/* 스텝 선택 드롭다운 */}
+ + {/* 테이블 */} +
+ {selectedStep && ( + + )} +
+
+ ); +}; +``` + +--- + +### Phase 5: InteractiveScreenViewer 통합 + +**파일**: `components/screen/InteractiveScreenViewer.tsx` + +```typescript +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; +import { TableOptionsToolbar } from "@/components/screen/table-options/TableOptionsToolbar"; + +export const InteractiveScreenViewer: React.FC = ({ screenData }) => { + return ( + +
+ {/* 테이블 옵션 툴바 */} + + + {/* 화면 컨텐츠 */} +
+ {screenData.components.map((component) => ( + + ))} +
+
+
+ ); +}; +``` + +--- + +### Phase 6: 백엔드 API 개선 + +**파일**: `backend-node/src/controllers/tableController.ts` + +```typescript +/** + * 테이블 데이터 조회 (필터/그룹 지원) + */ +export async function getTableData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { tableName, filters, groupBy, page = 1, pageSize = 50 } = req.query; + + try { + // 필터 파싱 + const parsedFilters: TableFilter[] = filters + ? JSON.parse(filters as string) + : []; + + // WHERE 절 생성 + const whereConditions: string[] = [`company_code = $1`]; + const params: any[] = [companyCode]; + + parsedFilters.forEach((filter, index) => { + const paramIndex = index + 2; + + switch (filter.operator) { + case "equals": + whereConditions.push(`${filter.columnName} = $${paramIndex}`); + params.push(filter.value); + break; + case "contains": + whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`); + params.push(`%${filter.value}%`); + break; + case "startsWith": + whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`); + params.push(`${filter.value}%`); + break; + case "endsWith": + whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`); + params.push(`%${filter.value}`); + break; + case "gt": + whereConditions.push(`${filter.columnName} > $${paramIndex}`); + params.push(filter.value); + break; + case "lt": + whereConditions.push(`${filter.columnName} < $${paramIndex}`); + params.push(filter.value); + break; + case "gte": + whereConditions.push(`${filter.columnName} >= $${paramIndex}`); + params.push(filter.value); + break; + case "lte": + whereConditions.push(`${filter.columnName} <= $${paramIndex}`); + params.push(filter.value); + break; + case "notEquals": + whereConditions.push(`${filter.columnName} != $${paramIndex}`); + params.push(filter.value); + break; + } + }); + + const whereSql = `WHERE ${whereConditions.join(" AND ")}`; + const groupBySql = groupBy ? `GROUP BY ${groupBy}` : ""; + + // 페이징 + const offset = + (parseInt(page as string) - 1) * parseInt(pageSize as string); + const limitSql = `LIMIT ${pageSize} OFFSET ${offset}`; + + // 카운트 쿼리 + const countQuery = `SELECT COUNT(*) as total FROM ${tableName} ${whereSql}`; + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 데이터 쿼리 + const dataQuery = ` + SELECT * FROM ${tableName} + ${whereSql} + ${groupBySql} + ORDER BY id DESC + ${limitSql} + `; + const dataResult = await pool.query(dataQuery, params); + + return res.json({ + success: true, + data: dataResult.rows, + pagination: { + page: parseInt(page as string), + pageSize: parseInt(pageSize as string), + total, + totalPages: Math.ceil(total / parseInt(pageSize as string)), + }, + }); + } catch (error: any) { + logger.error("테이블 데이터 조회 실패", { + error: error.message, + tableName, + }); + return res.status(500).json({ + success: false, + error: "데이터 조회 중 오류가 발생했습니다", + }); + } +} +``` + +--- + +## 5. 파일 구조 + +``` +frontend/ +├── types/ +│ └── table-options.ts # 타입 정의 +│ +├── contexts/ +│ └── TableOptionsContext.tsx # Context 및 Provider +│ +├── components/ +│ └── screen/ +│ ├── table-options/ +│ │ ├── TableOptionsToolbar.tsx # 메인 툴바 +│ │ ├── ColumnVisibilityPanel.tsx # 테이블 옵션 패널 +│ │ ├── FilterPanel.tsx # 필터 설정 패널 +│ │ └── GroupingPanel.tsx # 그룹 설정 패널 +│ │ +│ ├── interactive/ +│ │ ├── TableList.tsx # 수정: Context 연동 +│ │ ├── SplitPanel.tsx # 수정: Context 연동 +│ │ └── FlowWidget.tsx # 수정: Context 연동 +│ │ +│ └── InteractiveScreenViewer.tsx # 수정: Provider 래핑 +│ +backend-node/ +└── src/ + └── controllers/ + └── tableController.ts # 수정: 필터/그룹 지원 +``` + +--- + +## 6. 통합 시나리오 + +### 6.1 단일 테이블 화면 + +```tsx + + + {/* 자동으로 1개 테이블 선택 */} + {/* 자동 등록 */} + + +``` + +**동작 흐름**: + +1. TableList 마운트 → Context에 테이블 등록 +2. TableOptionsToolbar에서 자동으로 해당 테이블 선택 +3. 사용자가 필터 설정 → onFilterChange 콜백 호출 +4. TableList에서 filters 상태 업데이트 → 데이터 재조회 + +### 6.2 다중 테이블 화면 (SplitPanel) + +```tsx + + + {/* 좌/우 테이블 선택 가능 */} + + {" "} + {/* 좌/우 각각 등록 */} + {/* 좌측 */} + {/* 우측 */} + + + +``` + +**동작 흐름**: + +1. SplitPanel 마운트 → 좌/우 테이블 각각 등록 +2. TableOptionsToolbar에서 드롭다운으로 테이블 선택 +3. 선택된 테이블에 대해서만 옵션 적용 +4. 각 테이블의 상태는 독립적으로 관리 + +### 6.3 플로우 위젯 화면 + +```tsx + + + {/* 현재 스텝 테이블 자동 선택 */} + {/* 스텝 변경 시 자동 재등록 */} + + +``` + +**동작 흐름**: + +1. FlowWidget 마운트 → 초기 스텝 테이블 등록 +2. 사용자가 다른 스텝 선택 → 기존 테이블 해제 + 새 테이블 등록 +3. TableOptionsToolbar에서 자동으로 새 테이블 선택 +4. 스텝별로 독립적인 필터/그룹 설정 유지 + +--- + +## 7. 주요 기능 및 개선 사항 + +### 7.1 자동 감지 메커니즘 + +**구현 방법**: + +- 각 테이블 컴포넌트가 마운트될 때 `registerTable()` 호출 +- 언마운트 시 `unregisterTable()` 호출 +- Context가 등록된 테이블 목록을 Map으로 관리 + +**장점**: + +- 개발자가 수동으로 테이블 목록을 관리할 필요 없음 +- 동적으로 컴포넌트가 추가/제거되어도 자동 반영 +- 컴포넌트 간 느슨한 결합 유지 + +### 7.2 독립적 상태 관리 + +**구현 방법**: + +- 각 테이블 컴포넌트가 자체 상태(filters, grouping, columnVisibility) 관리 +- Context는 상태를 직접 저장하지 않고 콜백 함수만 저장 +- 콜백을 통해 각 테이블에 설정 전달 + +**장점**: + +- 한 테이블의 설정이 다른 테이블에 영향 없음 +- 메모리 효율적 (Context에 모든 상태 저장 불필요) +- 각 테이블이 독립적으로 최적화 가능 + +### 7.3 실시간 반영 + +**구현 방법**: + +- 옵션 변경 시 즉시 해당 테이블의 콜백 호출 +- 테이블 컴포넌트는 상태 변경을 감지하여 자동 리렌더링 +- useCallback과 useMemo로 불필요한 리렌더링 방지 + +**장점**: + +- 사용자 경험 향상 (즉각적인 피드백) +- 성능 최적화 (변경된 테이블만 업데이트) + +### 7.4 확장성 + +**새로운 테이블 컴포넌트 추가 방법**: + +```typescript +export const MyCustomTable: React.FC = () => { + const { registerTable, unregisterTable } = useTableOptions(); + const [filters, setFilters] = useState([]); + + useEffect(() => { + registerTable({ + tableId: "my-custom-table-123", + label: "커스텀 테이블", + tableName: "custom_table", + columns: [...], + onFilterChange: setFilters, + onGroupChange: setGrouping, + onColumnVisibilityChange: setColumnVisibility, + }); + + return () => unregisterTable("my-custom-table-123"); + }, []); + + // 나머지 구현... +}; +``` + +--- + +## 8. 예상 장점 + +### 8.1 개발자 측면 + +1. **코드 재사용성**: 공통 로직을 한 곳에서 관리 +2. **유지보수 용이**: 버그 수정 시 한 곳만 수정 +3. **일관된 UX**: 모든 테이블에서 동일한 사용자 경험 +4. **빠른 개발**: 새 테이블 추가 시 Context만 연동 + +### 8.2 사용자 측면 + +1. **직관적인 UI**: 통일된 인터페이스로 학습 비용 감소 +2. **유연한 검색**: 다양한 필터 조합으로 원하는 데이터 빠르게 찾기 +3. **맞춤 설정**: 각 테이블별로 컬럼 표시/숨김 설정 가능 +4. **효율적인 작업**: 그룹화로 대량 데이터를 구조적으로 확인 + +### 8.3 성능 측면 + +1. **최적화된 렌더링**: 변경된 테이블만 리렌더링 +2. **효율적인 상태 관리**: Context에 최소한의 정보만 저장 +3. **지연 로딩**: 패널은 열릴 때만 렌더링 +4. **백엔드 부하 감소**: 필터링된 데이터만 조회 + +--- + +## 9. 구현 우선순위 + +### Phase 1: 기반 구조 (1-2일) + +- [ ] 타입 정의 작성 +- [ ] Context 및 Provider 구현 +- [ ] 테스트용 간단한 TableOptionsToolbar 작성 + +### Phase 2: 툴바 및 패널 (2-3일) + +- [ ] TableOptionsToolbar 완성 +- [ ] ColumnVisibilityPanel 구현 +- [ ] FilterPanel 구현 +- [ ] GroupingPanel 구현 + +### Phase 3: 기존 컴포넌트 통합 (2-3일) + +- [ ] TableList Context 연동 +- [ ] SplitPanel Context 연동 (좌/우 분리) +- [ ] FlowWidget Context 연동 +- [ ] InteractiveScreenViewer Provider 래핑 + +### Phase 4: 백엔드 API (1-2일) + +- [ ] 필터 처리 로직 구현 +- [ ] 그룹화 처리 로직 구현 +- [ ] 페이징 최적화 +- [ ] 성능 테스트 + +### Phase 5: 테스트 및 최적화 (1-2일) + +- [ ] 단위 테스트 작성 +- [ ] 통합 테스트 +- [ ] 성능 프로파일링 +- [ ] 버그 수정 및 최적화 + +**총 예상 기간**: 약 7-12일 + +--- + +## 10. 체크리스트 + +### 개발 전 확인사항 + +- [ ] 현재 테이블 옵션 기능 목록 정리 +- [ ] 기존 코드의 중복 로직 파악 +- [ ] 백엔드 API 현황 파악 +- [ ] 성능 요구사항 정의 + +### 개발 중 확인사항 + +- [ ] 타입 정의 완료 +- [ ] Context 및 Provider 동작 테스트 +- [ ] 각 패널 UI/UX 검토 +- [ ] 기존 컴포넌트와의 호환성 확인 +- [ ] 백엔드 API 연동 테스트 + +### 개발 후 확인사항 + +- [ ] 모든 테이블 컴포넌트에서 정상 작동 +- [ ] 다중 테이블 화면에서 독립성 확인 +- [ ] 성능 요구사항 충족 확인 +- [ ] 사용자 테스트 및 피드백 반영 +- [ ] 문서화 완료 + +### 배포 전 확인사항 + +- [ ] 기존 화면에 영향 없는지 확인 +- [ ] 롤백 계획 수립 +- [ ] 사용자 가이드 작성 +- [ ] 팀 공유 및 교육 + +--- + +## 11. 주의사항 + +### 11.1 멀티테넌시 준수 + +모든 데이터 조회 시 `company_code` 필터링 필수: + +```typescript +// ✅ 올바른 방법 +const whereConditions: string[] = [`company_code = $1`]; +const params: any[] = [companyCode]; + +// ❌ 잘못된 방법 +const whereConditions: string[] = []; // company_code 필터링 누락 +``` + +### 11.2 SQL 인젝션 방지 + +필터 값은 반드시 파라미터 바인딩 사용: + +```typescript +// ✅ 올바른 방법 +whereConditions.push(`${filter.columnName} = $${paramIndex}`); +params.push(filter.value); + +// ❌ 잘못된 방법 +whereConditions.push(`${filter.columnName} = '${filter.value}'`); // SQL 인젝션 위험 +``` + +### 11.3 성능 고려사항 + +- 컬럼이 많은 테이블(100개 이상)의 경우 가상 스크롤 적용 +- 필터 변경 시 디바운싱으로 API 호출 최소화 +- 그룹화는 데이터량에 따라 프론트엔드/백엔드 선택적 처리 + +### 11.4 접근성 + +- 키보드 네비게이션 지원 (Tab, Enter, Esc) +- 스크린 리더 호환성 확인 +- 색상 대비 4.5:1 이상 유지 + +--- + +## 12. 추가 고려사항 + +### 12.1 설정 저장 기능 + +사용자별로 테이블 설정을 저장하여 화면 재방문 시 복원: + +```typescript +// 로컬 스토리지에 저장 +localStorage.setItem( + `table-settings-${tableId}`, + JSON.stringify({ columnVisibility, filters, grouping }) +); + +// 불러오기 +const savedSettings = localStorage.getItem(`table-settings-${tableId}`); +if (savedSettings) { + const { columnVisibility, filters, grouping } = JSON.parse(savedSettings); + setColumnVisibility(columnVisibility); + setFilters(filters); + setGrouping(grouping); +} +``` + +### 12.2 내보내기 기능 + +현재 필터/그룹 설정으로 Excel 내보내기: + +```typescript +const exportToExcel = () => { + const params = { + tableName: component.tableName, + filters: JSON.stringify(filters), + groupBy: grouping.join(","), + columns: visibleColumns.map((c) => c.field), + }; + + window.location.href = `/api/table/export?${new URLSearchParams(params)}`; +}; +``` + +### 12.3 필터 프리셋 + +자주 사용하는 필터 조합을 프리셋으로 저장: + +```typescript +interface FilterPreset { + id: string; + name: string; + filters: TableFilter[]; + grouping: string[]; +} + +const presets: FilterPreset[] = [ + { id: "active-items", name: "활성 품목만", filters: [...], grouping: [] }, + { id: "by-category", name: "카테고리별 그룹", filters: [], grouping: ["category_id"] }, +]; +``` + +--- + +## 13. 참고 자료 + +- [Tanstack Table 문서](https://tanstack.com/table/v8) +- [shadcn/ui Dialog 컴포넌트](https://ui.shadcn.com/docs/components/dialog) +- [React Context 최적화 가이드](https://react.dev/learn/passing-data-deeply-with-context) +- [PostgreSQL 필터링 최적화](https://www.postgresql.org/docs/current/indexes.html) + +--- + +## 14. 브라우저 테스트 결과 + +### 테스트 환경 + +- **날짜**: 2025-01-13 +- **브라우저**: Chrome +- **테스트 URL**: http://localhost:9771/screens/106 +- **화면**: DTG 수명주기 관리 - 스텝 (FlowWidget) + +### 테스트 항목 및 결과 + +#### ✅ 1. 테이블 옵션 (ColumnVisibilityPanel) + +- **상태**: 정상 동작 +- **테스트 내용**: + - 툴바의 "테이블 옵션" 버튼 클릭 시 다이얼로그 정상 표시 + - 7개 컬럼 모두 정상 표시 (장치 코드, 시리얼넘버, manufacturer, 모델명, 품번, 차량 타입, 차량 번호) + - 각 컬럼마다 체크박스, 드래그 핸들, 미리보기 아이콘, 너비 설정 표시 + - "초기화" 버튼 표시 +- **스크린샷**: `column-visibility-panel.png` + +#### ✅ 2. 필터 설정 (FilterPanel) + +- **상태**: 정상 동작 +- **테스트 내용**: + - 툴바의 "필터 설정" 버튼 클릭 시 다이얼로그 정상 표시 + - "총 0개의 검색 필터가 표시됩니다" 메시지 표시 + - "필터 추가" 버튼 정상 표시 + - "초기화" 버튼 표시 +- **스크린샷**: `filter-panel-empty.png` + +#### ✅ 3. 그룹 설정 (GroupingPanel) + +- **상태**: 정상 동작 +- **테스트 내용**: + - 툴바의 "그룹 설정" 버튼 클릭 시 다이얼로그 정상 표시 + - "0개 컬럼으로 그룹화" 메시지 표시 + - 7개 컬럼 모두 체크박스로 표시 + - 각 컬럼의 라벨 및 필드명 정상 표시 + - "초기화" 버튼 표시 +- **스크린샷**: `grouping-panel.png` + +#### ✅ 4. Context 통합 + +- **상태**: 정상 동작 +- **테스트 내용**: + - `TableOptionsProvider`가 `/screens/[screenId]/page.tsx`에 정상 통합 + - `FlowWidget` 컴포넌트가 `TableOptionsContext`에 정상 등록 + - 에러 없이 페이지 로드 및 렌더링 완료 + +### 검증 완료 사항 + +1. ✅ 타입 정의 및 Context 구현 완료 +2. ✅ 패널 컴포넌트 3개 구현 완료 (ColumnVisibility, Filter, Grouping) +3. ✅ TableOptionsToolbar 메인 컴포넌트 구현 완료 +4. ✅ TableOptionsProvider 통합 완료 +5. ✅ FlowWidget에 Context 연동 완료 +6. ✅ 브라우저 테스트 완료 (모든 기능 정상 동작) + +### 향후 개선 사항 + +1. **백엔드 API 통합**: 현재는 프론트엔드 상태 관리만 구현됨. 백엔드 API에 필터/그룹/컬럼 설정 파라미터 전달 필요 +2. **필터 적용 로직**: 필터 추가 후 실제 데이터 필터링 구현 +3. **그룹화 적용 로직**: 그룹 선택 후 실제 데이터 그룹화 구현 +4. **컬럼 순서/너비 적용**: 드래그앤드롭으로 변경한 순서 및 너비를 실제 테이블에 반영 + +--- + +## 15. 변경 이력 + +| 날짜 | 버전 | 변경 내용 | 작성자 | +| ---------- | ---- | -------------------------------------------- | ------ | +| 2025-01-13 | 1.0 | 초안 작성 | AI | +| 2025-01-13 | 1.1 | 프론트엔드 구현 완료 및 브라우저 테스트 완료 | AI | + +--- + +## 16. 구현 완료 요약 + +### 생성된 파일 + +1. `frontend/types/table-options.ts` - 타입 정의 +2. `frontend/contexts/TableOptionsContext.tsx` - Context 구현 +3. `frontend/components/screen/table-options/ColumnVisibilityPanel.tsx` - 컬럼 가시성 패널 +4. `frontend/components/screen/table-options/FilterPanel.tsx` - 필터 패널 +5. `frontend/components/screen/table-options/GroupingPanel.tsx` - 그룹핑 패널 +6. `frontend/components/screen/table-options/TableOptionsToolbar.tsx` - 메인 툴바 + +### 수정된 파일 + +1. `frontend/app/(main)/screens/[screenId]/page.tsx` - Provider 통합 (화면 뷰어) +2. `frontend/components/screen/ScreenDesigner.tsx` - Provider 통합 (화면 디자이너) +3. `frontend/components/screen/InteractiveDataTable.tsx` - Context 연동 +4. `frontend/components/screen/widgets/FlowWidget.tsx` - Context 연동 +5. `frontend/lib/registry/components/table-list/TableListComponent.tsx` - Context 연동 +6. `frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx` - Context 연동 + +### 구현 완료 기능 + +- ✅ Context API 기반 테이블 자동 감지 시스템 +- ✅ 컬럼 표시/숨기기, 순서 변경, 너비 설정 +- ✅ 필터 추가 UI (백엔드 연동 대기) +- ✅ 그룹화 컬럼 선택 UI (백엔드 연동 대기) +- ✅ 여러 테이블 컴포넌트 지원 (FlowWidget, TableList, SplitPanel, InteractiveDataTable) +- ✅ shadcn/ui 기반 일관된 디자인 시스템 +- ✅ 브라우저 테스트 완료 + +--- + +이 계획서를 검토하신 후 수정사항이나 추가 요구사항을 알려주세요! diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index d7ea2039..ebfbd3e7 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; +import React, { useEffect, useState, useMemo } from "react"; +import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; -import { ScreenDefinition, LayoutData } from "@/types/screen"; +import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; @@ -18,11 +18,17 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보 import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지 +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 +import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 -export default function ScreenViewPage() { +function ScreenViewPage() { const params = useParams(); + const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); + + // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) + const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; // 🆕 현재 로그인한 사용자 정보 const { user, userName, companyCode } = useAuth(); @@ -30,6 +36,9 @@ export default function ScreenViewPage() { // 🆕 모바일 환경 감지 const { isMobile } = useResponsive(); + // 🆕 TableSearchWidget 높이 관리 + const { getHeightDiff } = useTableSearchWidgetHeight(); + const [screen, setScreen] = useState(null); const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); @@ -294,16 +303,17 @@ export default function ScreenViewPage() { return ( -
- {/* 레이아웃 준비 중 로딩 표시 */} - {!layoutReady && ( -
-
- -

화면 준비 중...

-
-
- )} + +
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

+
+
+ )} {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? ( @@ -387,10 +397,49 @@ export default function ScreenViewPage() { const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + // TableSearchWidget들을 먼저 찾기 + const tableSearchWidgets = regularComponents.filter( + (c) => (c as any).componentId === "table-search-widget" + ); + + // TableSearchWidget 높이 차이를 계산하여 Y 위치 조정 + const adjustedComponents = regularComponents.map((component) => { + const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + + if (isTableSearchWidget) { + // TableSearchWidget 자체는 조정하지 않음 + return component; + } + + let totalHeightAdjustment = 0; + + for (const widget of tableSearchWidgets) { + // 현재 컴포넌트가 이 위젯 아래에 있는지 확인 + const isBelow = component.position.y > widget.position.y; + const heightDiff = getHeightDiff(screenId, widget.id); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } + } + + if (totalHeightAdjustment > 0) { + return { + ...component, + position: { + ...component.position, + y: component.position.y + totalHeightAdjustment, + }, + }; + } + + return component; + }); + return ( <> {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => { + {adjustedComponents.map((component) => { // 화면 관리 해상도를 사용하므로 위치 조정 불필요 return ( {}} + menuObjid={menuObjid} screenId={screenId} tableName={screen?.tableName} userId={user?.userId} userName={userName} companyCode={companyCode} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} @@ -463,11 +514,13 @@ export default function ScreenViewPage() { isSelected={false} isDesignMode={false} onClick={() => {}} + menuObjid={menuObjid} screenId={screenId} tableName={screen?.tableName} userId={user?.userId} userName={userName} companyCode={companyCode} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} @@ -671,33 +724,45 @@ export default function ScreenViewPage() {
)} - {/* 편집 모달 */} - { - setEditModalOpen(false); - setEditModalConfig({}); - }} - screenId={editModalConfig.screenId} - modalSize={editModalConfig.modalSize} - editData={editModalConfig.editData} - onSave={editModalConfig.onSave} - modalTitle={editModalConfig.modalTitle} - modalDescription={editModalConfig.modalDescription} - onDataChange={(changedFormData) => { - console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); - // 변경된 데이터를 메인 폼에 반영 - setFormData((prev) => { - const updatedFormData = { - ...prev, - ...changedFormData, // 변경된 필드들만 업데이트 - }; - console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); - return updatedFormData; - }); - }} - /> -
+ {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + modalTitle={editModalConfig.modalTitle} + modalDescription={editModalConfig.modalDescription} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + /> +
+ ); } + +// 실제 컴포넌트를 Provider로 감싸기 +function ScreenViewPageWrapper() { + return ( + + + + ); +} + +export default ScreenViewPageWrapper; diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6823e2d5..be16f68d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -217,6 +217,18 @@ select:focus-visible { outline-offset: 2px; } +/* TableSearchWidget의 SelectTrigger 포커스 스타일 제거 */ +[role="combobox"]:focus-visible { + outline: none !important; + box-shadow: none !important; +} + +button[role="combobox"]:focus-visible { + outline: none !important; + box-shadow: none !important; + border-color: hsl(var(--input)) !important; +} + /* ===== Scrollbar Styles (Optional) ===== */ /* Webkit 기반 브라우저 (Chrome, Safari, Edge) */ ::-webkit-scrollbar { diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 09ddfe5c..ce08c522 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -853,7 +853,9 @@ export function CanvasElement({ )} {/* 제목 */} {!element.type || element.type !== "chart" ? ( - {element.customTitle || element.title} + element.subtype === "map-summary-v2" && !element.customTitle ? null : ( + {element.customTitle || element.title} + ) ) : null}
diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 710abbe6..7ca9684b 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -152,7 +152,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setCustomTitle(element.customTitle || ""); setShowHeader(element.showHeader !== false); setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); - setDataSources(element.dataSources || []); + // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴 + setDataSources(element.dataSources || element.chartConfig?.dataSources || []); setQueryResult(null); // 리스트 위젯 설정 초기화 @@ -297,10 +298,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge ...(needsDataSource(element.subtype) ? { dataSource, - // 다중 데이터 소스 위젯은 dataSources도 포함 + // 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제) ...(isMultiDataSourceWidget ? { - dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], + dataSources: dataSources, + // chartConfig에도 dataSources 포함 (일부 위젯은 chartConfig에서 읽음) + chartConfig: { ...chartConfig, dataSources: dataSources }, } : {}), } @@ -316,14 +319,14 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge element.subtype === "chart" || ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype) ? { - // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 + // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 (빈 배열도 허용 - 연결 해제) chartConfig: isMultiDataSourceWidget - ? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] } + ? { ...chartConfig, dataSources: dataSources } : chartConfig, - // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 + // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 (빈 배열도 허용 - 연결 해제) ...(isMultiDataSourceWidget ? { - dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], + dataSources: dataSources, } : {}), } @@ -520,7 +523,39 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge )} {/* 지도 설정 */} - {element.subtype === "map-summary-v2" && } + {element.subtype === "map-summary-v2" && ( + { + setElement((prev) => + prev + ? { + ...prev, + chartConfig: { + ...prev.chartConfig, + refreshInterval: interval, + }, + } + : prev + ); + }} + onMarkerTypeChange={(type) => { + setElement((prev) => + prev + ? { + ...prev, + chartConfig: { + ...prev.chartConfig, + markerType: type, + }, + } + : prev + ); + }} + /> + )} {/* 리스크 알림 설정 */} {element.subtype === "risk-alert-v2" && } @@ -534,7 +569,22 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge -
diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index bbbf7d4d..c72cb18e 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -530,31 +530,50 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M ))} - {/* 자동 새로고침 설정 */} + {/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
-
+ + {/* 마커 종류 선택 (MapTestWidgetV2 전용) */} +
+ + +

+ 지도에 표시할 마커의 모양을 선택합니다

@@ -892,6 +911,128 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M

)} + + {/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */} + {availableColumns.length > 0 && ( +
+ + + {/* 기존 팝업 필드 목록 */} + {dataSource.popupFields && dataSource.popupFields.length > 0 && ( +
+ {dataSource.popupFields.map((field, index) => ( +
+
+ 필드 {index + 1} + +
+ + {/* 필드명 선택 */} +
+ + +
+ + {/* 라벨 입력 */} +
+ + { + const newFields = [...(dataSource.popupFields || [])]; + newFields[index].label = e.target.value; + onChange({ popupFields: newFields }); + }} + placeholder="예: 차량 번호" + className="h-8 w-full text-xs" + dir="ltr" + /> +
+ + {/* 포맷 선택 */} +
+ + +
+
+ ))} +
+ )} + + {/* 필드 추가 버튼 */} + + +

+ 마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요 +

+
+ )} ); } diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index 76986718..73b2ab4b 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Loader2, CheckCircle, XCircle, Plus, Trash2 } from "lucide-react"; interface MultiDatabaseConfigProps { dataSource: ChartDataSource; @@ -45,13 +45,15 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult // ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함) const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); - + console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개"); - setExternalConnections(connections.map((conn: any) => ({ - id: String(conn.id), - name: conn.connection_name, - type: conn.db_type, - }))); + setExternalConnections( + connections.map((conn: any) => ({ + id: String(conn.id), + name: conn.connection_name, + type: conn.db_type, + })), + ); } catch (error) { console.error("❌ 외부 DB 커넥션 로드 실패:", error); setExternalConnections([]); @@ -73,27 +75,27 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult try { // dashboardApi 사용 (인증 토큰 자동 포함) const { dashboardApi } = await import("@/lib/api/dashboard"); - + if (dataSource.connectionType === "external" && dataSource.externalConnectionId) { // 외부 DB const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const result = await ExternalDbConnectionAPI.executeQuery( parseInt(dataSource.externalConnectionId), - dataSource.query + dataSource.query, ); - + if (result.success && result.data) { const rows = Array.isArray(result.data.rows) ? result.data.rows : []; const rowCount = rows.length; - + // 컬럼 목록 및 타입 추출 if (rows.length > 0) { const columns = Object.keys(rows[0]); setAvailableColumns(columns); - + // 컬럼 타입 분석 const types: Record = {}; - columns.forEach(col => { + columns.forEach((col) => { const value = rows[0][col]; if (value === null || value === undefined) { types[col] = "unknown"; @@ -113,17 +115,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }); setColumnTypes(types); setSampleData(rows.slice(0, 3)); - + console.log("📊 발견된 컬럼:", columns); console.log("📊 컬럼 타입:", types); } - + setTestResult({ success: true, message: "쿼리 실행 성공", rowCount, }); - + // 부모로 테스트 결과 전달 (차트 설정용) if (onTestResult && rows && rows.length > 0) { onTestResult(rows); @@ -134,15 +136,15 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult } else { // 현재 DB const result = await dashboardApi.executeQuery(dataSource.query); - + // 컬럼 목록 및 타입 추출 if (result.rows && result.rows.length > 0) { const columns = Object.keys(result.rows[0]); setAvailableColumns(columns); - + // 컬럼 타입 분석 const types: Record = {}; - columns.forEach(col => { + columns.forEach((col) => { const value = result.rows[0][col]; if (value === null || value === undefined) { types[col] = "unknown"; @@ -162,17 +164,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }); setColumnTypes(types); setSampleData(result.rows.slice(0, 3)); - + console.log("📊 발견된 컬럼:", columns); console.log("📊 컬럼 타입:", types); } - + setTestResult({ success: true, message: "쿼리 실행 성공", rowCount: result.rowCount || 0, }); - + // 부모로 테스트 결과 전달 (차트 설정용) if (onTestResult && result.rows && result.rows.length > 0) { onTestResult(result.rows); @@ -194,25 +196,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult - onChange({ connectionType: value }) - } + onValueChange={(value: "current" | "external") => onChange({ connectionType: value })} >
- -
- -
@@ -222,12 +216,12 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult {/* 외부 DB 선택 */} {dataSource.connectionType === "external" && (
-