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..3f599fa5 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -1599,3 +1599,114 @@ 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) {
+ return res.status(400).json({
+ success: false,
+ message: "메뉴 OBJID가 필요합니다.",
+ });
+ }
+
+ // 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) {
+ return res.json({
+ success: true,
+ data: [],
+ message: "형제 메뉴에 연결된 테이블이 없습니다.",
+ });
+ }
+
+ // 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..40c05861 100644
--- a/backend-node/src/services/commonCodeService.ts
+++ b/backend-node/src/services/commonCodeService.ts
@@ -66,7 +66,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 +74,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 +152,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 +216,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 +241,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 +268,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 +284,7 @@ export class CommonCodeService {
data.categoryNameEng || null,
data.description || null,
data.sortOrder || 0,
+ menuObjid,
companyCode,
createdBy,
createdBy,
@@ -234,7 +292,7 @@ export class CommonCodeService {
);
logger.info(
- `카테고리 생성 완료: ${data.categoryCode} (회사: ${companyCode})`
+ `카테고리 생성 완료: ${data.categoryCode} (메뉴: ${menuObjid}, 회사: ${companyCode})`
);
return category;
} catch (error) {
@@ -352,14 +410,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 +427,7 @@ export class CommonCodeService {
data.codeNameEng || null,
data.description || null,
data.sortOrder || 0,
+ menuObjid,
companyCode,
createdBy,
createdBy,
@@ -375,7 +435,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/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..b22beb88
--- /dev/null
+++ b/backend-node/src/services/menuService.ts
@@ -0,0 +1,147 @@
+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. 현재 메뉴 자신을 포함
+ const menuObjids = [menuObjid];
+
+ // 2. 현재 메뉴의 자식 메뉴들 조회
+ const childrenQuery = `
+ SELECT objid FROM menu_info
+ WHERE parent_obj_id = $1
+ ORDER BY objid
+ `;
+ const childrenResult = await pool.query(childrenQuery, [menuObjid]);
+
+ const childObjids = childrenResult.rows.map((row) => Number(row.objid));
+
+ // 3. 자신 + 자식을 합쳐서 정렬
+ const allObjids = Array.from(new Set([...menuObjids, ...childObjids])).sort((a, b) => a - b);
+
+ logger.debug("메뉴 스코프 조회 완료", {
+ menuObjid,
+ childCount: childObjids.length,
+ totalCount: allObjids.length
+ });
+
+ return allObjids;
+ } 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..db76bbee 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,33 @@ class NumberingRuleService {
}
/**
- * 현재 메뉴에서 사용 가능한 규칙 목록 조회
+ * 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프)
+ *
+ * 메뉴 스코프 규칙:
+ * - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함
+ * - 우선순위: menu (형제 메뉴) > table > global
*/
async getAvailableRulesForMenu(
companyCode: string,
menuObjid?: number
): Promise {
try {
- logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", {
+ logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode,
menuObjid,
});
const pool = getPool();
+ // 1. 형제 메뉴 OBJID 조회
+ let siblingObjids: number[] = [];
+ 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 +273,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 +299,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 +335,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 +429,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..29cad453 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,84 +80,164 @@ 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",
- table_name AS "tableName",
- column_name AS "columnName",
- value_code AS "valueCode",
- value_label AS "valueLabel",
- value_order AS "valueOrder",
- parent_value_id AS "parentValueId",
- depth,
- description,
- color,
- icon,
- is_active AS "isActive",
- is_default AS "isDefault",
- company_code AS "companyCode",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- created_by AS "createdBy",
- updated_by AS "updatedBy"
- FROM table_column_category_values
- WHERE table_name = $1
- AND column_name = $2
- `;
- params = [tableName, columnName];
+ if (menuObjid && siblingObjids.length > 0) {
+ // 메뉴 스코프 적용
+ query = `
+ SELECT
+ value_id AS "valueId",
+ table_name AS "tableName",
+ column_name AS "columnName",
+ value_code AS "valueCode",
+ value_label AS "valueLabel",
+ value_order AS "valueOrder",
+ parent_value_id AS "parentValueId",
+ depth,
+ description,
+ color,
+ icon,
+ is_active AS "isActive",
+ is_default AS "isDefault",
+ company_code AS "companyCode",
+ menu_objid AS "menuObjid",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt",
+ created_by AS "createdBy",
+ updated_by AS "updatedBy"
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ AND menu_objid = ANY($3)
+ `;
+ params = [tableName, columnName, siblingObjids];
+ } else {
+ // 테이블 스코프 (하위 호환성)
+ query = `
+ SELECT
+ value_id AS "valueId",
+ table_name AS "tableName",
+ column_name AS "columnName",
+ value_code AS "valueCode",
+ value_label AS "valueLabel",
+ value_order AS "valueOrder",
+ parent_value_id AS "parentValueId",
+ depth,
+ description,
+ color,
+ icon,
+ is_active AS "isActive",
+ is_default AS "isDefault",
+ company_code AS "companyCode",
+ menu_objid AS "menuObjid",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt",
+ created_by AS "createdBy",
+ updated_by AS "updatedBy"
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ `;
+ params = [tableName, columnName];
+ }
logger.info("최고 관리자 카테고리 값 조회");
} else {
// 일반 회사: 자신의 카테고리 값만 조회
- query = `
- SELECT
- value_id AS "valueId",
- table_name AS "tableName",
- column_name AS "columnName",
- value_code AS "valueCode",
- value_label AS "valueLabel",
- value_order AS "valueOrder",
- parent_value_id AS "parentValueId",
- depth,
- description,
- color,
- icon,
- is_active AS "isActive",
- is_default AS "isDefault",
- company_code AS "companyCode",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- created_by AS "createdBy",
- updated_by AS "updatedBy"
- FROM table_column_category_values
- WHERE table_name = $1
- AND column_name = $2
- AND company_code = $3
- `;
- params = [tableName, columnName, companyCode];
+ if (menuObjid && siblingObjids.length > 0) {
+ // 메뉴 스코프 적용
+ query = `
+ SELECT
+ value_id AS "valueId",
+ table_name AS "tableName",
+ column_name AS "columnName",
+ value_code AS "valueCode",
+ value_label AS "valueLabel",
+ value_order AS "valueOrder",
+ parent_value_id AS "parentValueId",
+ depth,
+ description,
+ color,
+ icon,
+ is_active AS "isActive",
+ is_default AS "isDefault",
+ company_code AS "companyCode",
+ menu_objid AS "menuObjid",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt",
+ created_by AS "createdBy",
+ updated_by AS "updatedBy"
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ AND menu_objid = ANY($3)
+ AND company_code = $4
+ `;
+ params = [tableName, columnName, siblingObjids, companyCode];
+ } else {
+ // 테이블 스코프 (하위 호환성)
+ query = `
+ SELECT
+ value_id AS "valueId",
+ table_name AS "tableName",
+ column_name AS "columnName",
+ value_code AS "valueCode",
+ value_label AS "valueLabel",
+ value_order AS "valueOrder",
+ parent_value_id AS "parentValueId",
+ depth,
+ description,
+ color,
+ icon,
+ is_active AS "isActive",
+ is_default AS "isDefault",
+ company_code AS "companyCode",
+ menu_objid AS "menuObjid",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt",
+ created_by AS "createdBy",
+ updated_by AS "updatedBy"
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ AND company_code = $3
+ `;
+ params = [tableName, columnName, companyCode];
+ }
logger.info("회사별 카테고리 값 조회", { companyCode });
}
@@ -173,6 +256,8 @@ class TableCategoryValueService {
tableName,
columnName,
companyCode,
+ menuObjid,
+ scopeType: menuObjid ? "menu" : "table",
});
return values;
@@ -183,17 +268,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 +304,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 +315,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 +331,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 +348,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 +367,7 @@ class TableCategoryValueService {
value.isActive !== false,
value.isDefault || false,
companyCode,
+ menuObjid, // ← 메뉴 OBJID 저장
userId,
]);
@@ -272,6 +375,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..fd2e82a7 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -1494,6 +1494,7 @@ export class TableManagementService {
search?: Record;
sortBy?: string;
sortOrder?: string;
+ companyCode?: string;
}
): Promise<{
data: any[];
@@ -1503,7 +1504,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 +1518,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 !== "") {
@@ -2213,11 +2222,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 +2361,7 @@ export class TableManagementService {
search?: Record;
sortBy?: string;
sortOrder?: string;
+ companyCode?: string;
},
startTime: number
): Promise {
@@ -2530,11 +2549,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 +2826,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 +2854,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 && (
+
- )}
-
- {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
-
- {/* Pan 모드 안내 - 제거됨 */}
- {/* 줌 레벨 표시 */}
-
- 🔍 {Math.round(zoomLevel * 100)}%
-
- {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
- {(() => {
- // 선택된 컴포넌트들
- const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
-
- // 버튼 컴포넌트만 필터링
- const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
-
- // 플로우 그룹에 속한 버튼이 있는지 확인
- const hasFlowGroupButton = selectedButtons.some((btn) => {
- const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
- return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
- });
-
- // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
- const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
-
- if (!shouldShow) return null;
-
- return (
-
-
-
-
- {selectedButtons.length}개 버튼 선택됨
-
-
- {/* 그룹 생성 버튼 (2개 이상 선택 시) */}
- {selectedButtons.length >= 2 && (
-
- )}
-
- {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
- {hasFlowGroupButton && (
-
- )}
-
- {/* 상태 표시 */}
- {hasFlowGroupButton &&
✓ 플로우 그룹 버튼
}
-
+ {/* 통합 패널 */}
+ {panelStates.unified?.isOpen && (
+
+
+
패널
+
- );
- })()}
- {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
-
+ )}
+
+ {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
+
+ {/* Pan 모드 안내 - 제거됨 */}
+ {/* 줌 레벨 표시 */}
+
+ 🔍 {Math.round(zoomLevel * 100)}%
+
+ {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
+ {(() => {
+ // 선택된 컴포넌트들
+ const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
+
+ // 버튼 컴포넌트만 필터링
+ const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
+
+ // 플로우 그룹에 속한 버튼이 있는지 확인
+ const hasFlowGroupButton = selectedButtons.some((btn) => {
+ const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
+ return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
+ });
+
+ // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
+ const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
+
+ if (!shouldShow) return null;
+
+ return (
+
+
+
+
+ {selectedButtons.length}개 버튼 선택됨
+
+
+ {/* 그룹 생성 버튼 (2개 이상 선택 시) */}
+ {selectedButtons.length >= 2 && (
+
+ )}
+
+ {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
+ {hasFlowGroupButton && (
+
+ )}
+
+ {/* 상태 표시 */}
+ {hasFlowGroupButton &&
✓ 플로우 그룹 버튼
}
+
+
+ );
+ })()}
+ {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}