diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index dbb99963..60936930 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -48,6 +48,8 @@ export const SplitPanelLayoutComponent: React.FC const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 + const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 + const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 const { toast } = useToast(); // 추가 모달 상태 @@ -270,6 +272,32 @@ export const SplitPanelLayoutComponent: React.FC [rightTableColumns], ); + // 좌측 테이블 컬럼 라벨 로드 + useEffect(() => { + const loadLeftColumnLabels = async () => { + const leftTableName = componentConfig.leftPanel?.tableName; + if (!leftTableName || isDesignMode) return; + + try { + const columnsResponse = await tableTypeApi.getColumns(leftTableName); + const labels: Record = {}; + columnsResponse.forEach((col: any) => { + const columnName = col.columnName || col.column_name; + const label = col.columnLabel || col.column_label || col.displayName || columnName; + if (columnName) { + labels[columnName] = label; + } + }); + setLeftColumnLabels(labels); + console.log("✅ 좌측 컬럼 라벨 로드:", labels); + } catch (error) { + console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); + } + }; + + loadLeftColumnLabels(); + }, [componentConfig.leftPanel?.tableName, isDesignMode]); + // 우측 테이블 컬럼 정보 로드 useEffect(() => { const loadRightTableColumns = async () => { @@ -279,6 +307,18 @@ export const SplitPanelLayoutComponent: React.FC try { const columnsResponse = await tableTypeApi.getColumns(rightTableName); setRightTableColumns(columnsResponse || []); + + // 우측 컬럼 라벨도 함께 로드 + const labels: Record = {}; + columnsResponse.forEach((col: any) => { + const columnName = col.columnName || col.column_name; + const label = col.columnLabel || col.column_label || col.displayName || columnName; + if (columnName) { + labels[columnName] = label; + } + }); + setRightColumnLabels(labels); + console.log("✅ 우측 컬럼 라벨 로드:", labels); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } @@ -784,46 +824,157 @@ export const SplitPanelLayoutComponent: React.FC )} - {/* 좌측 데이터 목록 */} -
- {isDesignMode ? ( - // 디자인 모드: 샘플 데이터 - <> -
handleLeftItemSelect({ id: 1, name: "항목 1" })} - className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ - selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : "" - }`} - > -
항목 1
-
설명 텍스트
+ {/* 좌측 데이터 목록/테이블 */} + {componentConfig.leftPanel?.displayMode === "table" ? ( + // 테이블 모드 +
+ {isDesignMode ? ( + // 디자인 모드: 샘플 테이블 +
+ + + + + + + + + + + + + + + + + + + + +
컬럼 1컬럼 2컬럼 3
데이터 1-1데이터 1-2데이터 1-3
데이터 2-1데이터 2-2데이터 2-3
-
handleLeftItemSelect({ id: 2, name: "항목 2" })} - className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ - selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : "" - }`} - > -
항목 2
-
설명 텍스트
+ ) : isLoadingLeft ? ( +
+ + 데이터를 불러오는 중...
-
handleLeftItemSelect({ id: 3, name: "항목 3" })} - className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ - selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : "" - }`} - > -
항목 3
-
설명 텍스트
+ ) : ( + (() => { + const filteredData = leftSearchQuery + ? leftData.filter((item) => { + const searchLower = leftSearchQuery.toLowerCase(); + return Object.entries(item).some(([key, value]) => { + if (value === null || value === undefined) return false; + return String(value).toLowerCase().includes(searchLower); + }); + }) + : leftData; + + const displayColumns = componentConfig.leftPanel?.columns || []; + const columnsToShow = displayColumns.length > 0 + ? displayColumns.map(col => ({ + ...col, + label: leftColumnLabels[col.name] || col.label || col.name + })) + : Object.keys(filteredData[0] || {}).filter(key => key !== 'children' && key !== 'level').slice(0, 5).map(key => ({ + name: key, + label: leftColumnLabels[key] || key, + width: 150, + align: "left" as const + })); + + return ( +
+ + + + {columnsToShow.map((col, idx) => ( + + ))} + + + + {filteredData.map((item, idx) => { + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; + const itemId = item[sourceColumn] || item.id || item.ID || idx; + const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); + + return ( + handleLeftItemSelect(item)} + className={`hover:bg-accent cursor-pointer transition-colors ${ + isSelected ? "bg-primary/10" : "" + }`} + > + {columnsToShow.map((col, colIdx) => ( + + ))} + + ); + })} + +
+ {col.label} +
+ {item[col.name] !== null && item[col.name] !== undefined + ? String(item[col.name]) + : "-"} +
+
+ ); + })() + )} +
+ ) : ( + // 목록 모드 (기존) +
+ {isDesignMode ? ( + // 디자인 모드: 샘플 데이터 + <> +
handleLeftItemSelect({ id: 1, name: "항목 1" })} + className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ + selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : "" + }`} + > +
항목 1
+
설명 텍스트
+
+
handleLeftItemSelect({ id: 2, name: "항목 2" })} + className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ + selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : "" + }`} + > +
항목 2
+
설명 텍스트
+
+
handleLeftItemSelect({ id: 3, name: "항목 3" })} + className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ + selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : "" + }`} + > +
항목 3
+
설명 텍스트
+
+ + ) : isLoadingLeft ? ( + // 로딩 중 +
+ + 데이터를 불러오는 중...
- - ) : isLoadingLeft ? ( - // 로딩 중 -
- - 데이터를 불러오는 중... -
- ) : ( + ) : ( (() => { // 검색 필터링 (클라이언트 사이드) const filteredLeftData = leftSearchQuery @@ -1001,7 +1152,8 @@ export const SplitPanelLayoutComponent: React.FC ); })() )} -
+
+ )}
@@ -1081,6 +1233,107 @@ export const SplitPanelLayoutComponent: React.FC }) : rightData; + // 테이블 모드 체크 + const isTableMode = componentConfig.rightPanel?.displayMode === "table"; + + if (isTableMode) { + // 테이블 모드 렌더링 + const displayColumns = componentConfig.rightPanel?.columns || []; + const columnsToShow = displayColumns.length > 0 + ? displayColumns.map(col => ({ + ...col, + label: rightColumnLabels[col.name] || col.label || col.name + })) + : Object.keys(filteredData[0] || {}).filter(key => !key.toLowerCase().includes("password")).slice(0, 5).map(key => ({ + name: key, + label: rightColumnLabels[key] || key, + width: 150, + align: "left" as const + })); + + return ( +
+
+ {filteredData.length}개의 관련 데이터 + {rightSearchQuery && filteredData.length !== rightData.length && ( + (전체 {rightData.length}개 중) + )} +
+
+ + + + {columnsToShow.map((col, idx) => ( + + ))} + {!isDesignMode && ( + + )} + + + + {filteredData.map((item, idx) => { + const itemId = item.id || item.ID || idx; + + return ( + + {columnsToShow.map((col, colIdx) => ( + + ))} + {!isDesignMode && ( + + )} + + ); + })} + +
+ {col.label} + 작업
+ {item[col.name] !== null && item[col.name] !== undefined + ? String(item[col.name]) + : "-"} + +
+ + +
+
+
+
+ ); + } + + // 목록 모드 (기존) return filteredData.length > 0 ? (
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 35e711fa..0b37ee26 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -353,6 +353,32 @@ export const SplitPanelLayoutConfigPanel: React.FC
+
+ + +
+
)} + {/* 좌측 패널 표시 컬럼 설정 */} +
+
+ + +
+

+ 좌측 패널에 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다. +

+ + {/* 선택된 컬럼 목록 */} +
+ {(config.leftPanel?.columns || []).length === 0 ? ( +
+

설정된 컬럼이 없습니다

+

+ 컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다 +

+
+ ) : ( + (config.leftPanel?.columns || []).map((col, index) => { + const isTableMode = config.leftPanel?.displayMode === "table"; + + return ( +
+
+
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns.map((column) => ( + { + const newColumns = [...(config.leftPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateLeftPanel({ columns: newColumns }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+ +
+ + {/* 테이블 모드 전용 옵션 */} + {isTableMode && ( +
+
+ + { + const newColumns = [...(config.leftPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + width: parseInt(e.target.value) || 100, + }; + updateLeftPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+
+ + +
+
+ +
+
+ )} +
+ ); + }) + )} +
+
+ {/* 좌측 패널 추가 모달 컬럼 설정 */} {config.leftPanel?.showAdd && (
@@ -895,6 +1100,32 @@ export const SplitPanelLayoutConfigPanel: React.FC )} +
+ + +
+ {/* 컬럼 매핑 - 조인 모드에서만 표시 */} {relationshipType !== "detail" && (
@@ -1057,75 +1288,145 @@ export const SplitPanelLayoutConfigPanel: React.FC
) : ( - (config.rightPanel?.columns || []).map((col, index) => ( + (config.rightPanel?.columns || []).map((col, index) => { + const isTableMode = config.rightPanel?.displayMode === "table"; + + return (
-
- - - - - - - - 컬럼을 찾을 수 없습니다. - - {rightTableColumns.map((column) => ( - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { - ...newColumns[index], - name: value, - label: column.columnLabel || value, - }; - updateRightPanel({ columns: newColumns }); - }} - className="text-xs" - > - - {column.columnLabel || column.columnName} - - ({column.columnName}) - - - ))} - - - - +
+
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {rightTableColumns.map((column) => ( + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateRightPanel({ columns: newColumns }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+
- + + {/* 테이블 모드 전용 옵션 */} + {isTableMode && ( +
+
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + width: parseInt(e.target.value) || 100, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+
+ + +
+
+ +
+
+ )}
- )) + ); + }) )}
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index df43221a..6f6421e5 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -8,6 +8,7 @@ export interface SplitPanelLayoutConfig { title: string; tableName?: string; // 데이터베이스 테이블명 dataSource?: string; // API 엔드포인트 + displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; // 수정 버튼 @@ -16,6 +17,8 @@ export interface SplitPanelLayoutConfig { name: string; label: string; width?: number; + sortable?: boolean; // 정렬 가능 여부 (테이블 모드) + align?: "left" | "center" | "right"; // 정렬 (테이블 모드) }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -38,6 +41,17 @@ export interface SplitPanelLayoutConfig { // 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code) sourceColumn: string; }; + // 테이블 모드 설정 + tableConfig?: { + showCheckbox?: boolean; // 체크박스 표시 여부 + showRowNumber?: boolean; // 행 번호 표시 여부 + rowHeight?: number; // 행 높이 + headerHeight?: number; // 헤더 높이 + striped?: boolean; // 줄무늬 배경 + bordered?: boolean; // 테두리 표시 + hoverable?: boolean; // 호버 효과 + stickyHeader?: boolean; // 헤더 고정 + }; }; // 우측 패널 설정 @@ -45,6 +59,7 @@ export interface SplitPanelLayoutConfig { title: string; tableName?: string; dataSource?: string; + displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; // 수정 버튼 @@ -53,6 +68,8 @@ export interface SplitPanelLayoutConfig { name: string; label: string; width?: number; + sortable?: boolean; // 정렬 가능 여부 (테이블 모드) + align?: "left" | "center" | "right"; // 정렬 (테이블 모드) }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -76,6 +93,18 @@ export interface SplitPanelLayoutConfig { leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 }; + + // 테이블 모드 설정 + tableConfig?: { + showCheckbox?: boolean; // 체크박스 표시 여부 + showRowNumber?: boolean; // 행 번호 표시 여부 + rowHeight?: number; // 행 높이 + headerHeight?: number; // 헤더 높이 + striped?: boolean; // 줄무늬 배경 + bordered?: boolean; // 테두리 표시 + hoverable?: boolean; // 호버 효과 + stickyHeader?: boolean; // 헤더 고정 + }; }; // 레이아웃 설정 diff --git a/카테고리_메뉴기반_전환_계획서.md b/카테고리_메뉴기반_전환_계획서.md new file mode 100644 index 00000000..ddc5b023 --- /dev/null +++ b/카테고리_메뉴기반_전환_계획서.md @@ -0,0 +1,977 @@ +# 카테고리 컴포넌트 메뉴 기반 전환 계획서 + +## 📋 현재 문제점 + +### 테이블 기반 스코프의 한계 + +**현재 상황**: + +- 카테고리와 채번 컴포넌트가 **테이블 기준**으로 데이터를 불러옴 +- `table_column_category_values` 테이블에서 `table_name + column_name`으로 카테고리 조회 + +**문제 발생**: + +``` +영업관리 (menu_id: 200) +├── 고객관리 (menu_id: 201) - 테이블: customer_info +├── 계약관리 (menu_id: 202) - 테이블: contract_info +├── 주문관리 (menu_id: 203) - 테이블: order_info +└── 영업관리 공통코드 (menu_id: 204) - 어떤 테이블 선택? +``` + +**문제**: + +- 영업관리 전체에서 사용할 공통 코드/카테고리를 관리하고 싶은데 +- 각 하위 메뉴가 서로 다른 테이블을 사용하므로 +- 특정 테이블 하나를 선택하면 다른 메뉴에서 사용할 수 없음 + +### 예시: 영업관리 공통 코드 관리 불가 + +**원하는 동작**: + +- "영업관리 > 공통코드 관리" 메뉴에서 카테고리 생성 +- 이 카테고리는 영업관리의 **모든 하위 메뉴**에서 사용 가능 +- 고객관리, 계약관리, 주문관리 화면 모두에서 같은 카테고리 공유 + +**현재 동작**: + +- 테이블별로 카테고리가 격리됨 +- `customer_info` 테이블의 카테고리는 `contract_info`에서 사용 불가 +- 각 테이블마다 동일한 카테고리를 중복 생성해야 함 (비효율) + +--- + +## ✅ 해결 방안: 메뉴 기반 스코프 + +### 핵심 개념 + +**메뉴 계층 구조를 카테고리 스코프로 사용**: + +- 카테고리를 생성할 때 `menu_id`를 기록 +- 같은 부모 메뉴를 가진 **형제 메뉴들**이 카테고리를 공유 +- 테이블과 무관하게 메뉴 구조에 따라 스코프 결정 + +### 메뉴 스코프 규칙 + +``` +영업관리 (parent_id: 0, menu_id: 200) +├── 고객관리 (parent_id: 200, menu_id: 201) +├── 계약관리 (parent_id: 200, menu_id: 202) +├── 주문관리 (parent_id: 200, menu_id: 203) +└── 공통코드 관리 (parent_id: 200, menu_id: 204) ← 여기서 카테고리 생성 +``` + +**스코프 규칙**: + +- 204번 메뉴에서 카테고리 생성 → `menu_id = 204`로 저장 +- 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능** +- 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가 + +--- + +## 📐 데이터베이스 설계 + +### 기존 테이블 수정 + +```sql +-- table_column_category_values 테이블에 menu_id 추가 +ALTER TABLE table_column_category_values +ADD COLUMN menu_id INTEGER; + +-- 외래키 추가 +ALTER TABLE table_column_category_values +ADD CONSTRAINT fk_category_value_menu +FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id); + +-- UNIQUE 제약조건 수정 (menu_id 추가) +ALTER TABLE table_column_category_values +DROP CONSTRAINT IF EXISTS unique_category_value; + +ALTER TABLE table_column_category_values +ADD CONSTRAINT unique_category_value +UNIQUE (table_name, column_name, value_code, menu_id, company_code); + +-- 인덱스 추가 +CREATE INDEX idx_category_value_menu +ON table_column_category_values(menu_id, table_name, column_name, company_code); +``` + +### 필드 설명 + +| 필드 | 설명 | 예시 | +| -------------- | ------------------------ | --------------------- | +| `table_name` | 어떤 테이블의 컬럼인지 | `customer_info` | +| `column_name` | 어떤 컬럼의 값인지 | `customer_type` | +| `menu_id` | 어느 메뉴에서 생성했는지 | `204` (공통코드 관리) | +| `company_code` | 멀티테넌시 | `COMPANY_A` | + +--- + +## 🔧 백엔드 구현 + +### 1. 메뉴 스코프 로직 추가 + +#### 형제 메뉴 조회 함수 + +```typescript +// backend-node/src/services/menuService.ts + +/** + * 메뉴의 형제 메뉴 ID 목록 조회 + * (같은 부모를 가진 메뉴들) + */ +export async function getSiblingMenuIds(menuId: number): Promise { + const pool = getPool(); + + // 1. 현재 메뉴의 부모 찾기 + const parentQuery = ` + SELECT parent_id FROM menu_info WHERE menu_id = $1 + `; + const parentResult = await pool.query(parentQuery, [menuId]); + + if (parentResult.rows.length === 0) { + return [menuId]; // 메뉴가 없으면 자기 자신만 + } + + const parentId = parentResult.rows[0].parent_id; + + if (!parentId || parentId === 0) { + // 최상위 메뉴인 경우 자기 자신만 + return [menuId]; + } + + // 2. 같은 부모를 가진 형제 메뉴들 조회 + const siblingsQuery = ` + SELECT menu_id FROM menu_info WHERE parent_id = $1 + `; + const siblingsResult = await pool.query(siblingsQuery, [parentId]); + + return siblingsResult.rows.map((row) => row.menu_id); +} +``` + +### 2. 카테고리 값 조회 API 수정 + +#### 서비스 로직 수정 + +```typescript +// backend-node/src/services/tableCategoryValueService.ts + +/** + * 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ +async getCategoryValues( + tableName: string, + columnName: string, + menuId: number, // ← 추가 + companyCode: string, + includeInactive: boolean = false +): Promise { + logger.info("카테고리 값 조회 (메뉴 스코프)", { + tableName, + columnName, + menuId, + companyCode, + }); + + const pool = getPool(); + + // 1. 형제 메뉴 ID 조회 + const siblingMenuIds = await getSiblingMenuIds(menuId); + + logger.info("형제 메뉴 ID 목록", { menuId, siblingMenuIds }); + + // 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", + menu_id AS "menuId", + created_at AS "createdAt", + created_by AS "createdBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND menu_id = ANY($3) -- ← 형제 메뉴 포함 + ${!includeInactive ? 'AND is_active = true' : ''} + ORDER BY value_order, value_label + `; + params = [tableName, columnName, siblingMenuIds]; + } 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_id AS "menuId", + created_at AS "createdAt", + created_by AS "createdBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND menu_id = ANY($3) -- ← 형제 메뉴 포함 + AND company_code = $4 -- ← 회사별 필터링 + ${!includeInactive ? 'AND is_active = true' : ''} + ORDER BY value_order, value_label + `; + params = [tableName, columnName, siblingMenuIds, companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`); + + return result.rows; +} +``` + +### 3. 카테고리 값 추가 API 수정 + +```typescript +/** + * 카테고리 값 추가 (menu_id 저장) + */ +async addCategoryValue( + value: TableCategoryValue, + menuId: number, // ← 추가 + companyCode: string, + userId: string +): Promise { + logger.info("카테고리 값 추가 (메뉴 스코프)", { + tableName: value.tableName, + columnName: value.columnName, + valueCode: value.valueCode, + menuId, + companyCode, + }); + + const pool = getPool(); + + const query = ` + 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, menu_id, -- ← menu_id 추가 + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + menu_id AS "menuId", + created_at AS "createdAt", + created_by AS "createdBy" + `; + + const result = await pool.query(query, [ + value.tableName, + value.columnName, + value.valueCode, + value.valueLabel, + value.valueOrder || 0, + value.parentValueId || null, + value.depth || 1, + value.description || null, + value.color || null, + value.icon || null, + value.isActive !== false, + value.isDefault || false, + companyCode, + menuId, // ← 카테고리 관리 화면의 menu_id + userId, + ]); + + logger.info("카테고리 값 추가 성공", { + valueId: result.rows[0].valueId, + menuId, + }); + + return result.rows[0]; +} +``` + +### 4. 컨트롤러 수정 + +```typescript +// backend-node/src/controllers/tableCategoryValueController.ts + +/** + * 카테고리 값 목록 조회 + */ +export async function getCategoryValues( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { menuId, includeInactive } = req.query; // ← menuId 추가 + const companyCode = req.user!.companyCode; + + if (!menuId) { + res.status(400).json({ + success: false, + message: "menuId는 필수입니다", + }); + return; + } + + const service = new TableCategoryValueService(); + const values = await service.getCategoryValues( + tableName, + columnName, + Number(menuId), // ← menuId 전달 + companyCode, + includeInactive === "true" + ); + + res.json({ + success: true, + data: values, + }); + } catch (error: any) { + logger.error("카테고리 값 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 값 조회 중 오류 발생", + error: error.message, + }); + } +} + +/** + * 카테고리 값 추가 + */ +export async function addCategoryValue( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuId, ...value } = req.body; // ← menuId 추가 + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + if (!menuId) { + res.status(400).json({ + success: false, + message: "menuId는 필수입니다", + }); + return; + } + + const service = new TableCategoryValueService(); + const newValue = await service.addCategoryValue( + value, + menuId, // ← menuId 전달 + companyCode, + userId + ); + + res.json({ + success: true, + data: newValue, + }); + } catch (error: any) { + logger.error("카테고리 값 추가 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 값 추가 중 오류 발생", + error: error.message, + }); + } +} +``` + +--- + +## 🎨 프론트엔드 구현 + +### 1. API 클라이언트 수정 + +```typescript +// frontend/lib/api/tableCategoryValue.ts + +/** + * 카테고리 값 목록 조회 (메뉴 스코프) + */ +export async function getCategoryValues( + tableName: string, + columnName: string, + menuId: number, // ← 추가 + includeInactive: boolean = false +) { + try { + const response = await apiClient.get<{ + success: boolean; + data: TableCategoryValue[]; + }>(`/table-categories/${tableName}/${columnName}/values`, { + params: { + menuId, // ← menuId 쿼리 파라미터 추가 + includeInactive, + }, + }); + return response.data; + } catch (error: any) { + console.error("카테고리 값 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 카테고리 값 추가 + */ +export async function addCategoryValue( + value: TableCategoryValue, + menuId: number // ← 추가 +) { + try { + const response = await apiClient.post<{ + success: boolean; + data: TableCategoryValue; + }>("/table-categories/values", { + ...value, + menuId, // ← menuId 포함 + }); + return response.data; + } catch (error: any) { + console.error("카테고리 값 추가 실패:", error); + return { success: false, error: error.message }; + } +} +``` + +### 2. CategoryColumnList 컴포넌트 수정 + +```typescript +// frontend/components/table-category/CategoryColumnList.tsx + +interface CategoryColumnListProps { + tableName: string; + menuId: number; // ← 추가 + selectedColumn: string | null; + onColumnSelect: (columnName: string, columnLabel: string) => void; +} + +export function CategoryColumnList({ + tableName, + menuId, // ← 추가 + selectedColumn, + onColumnSelect, +}: CategoryColumnListProps) { + const [columns, setColumns] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadCategoryColumns(); + }, [tableName, menuId]); // ← menuId 의존성 추가 + + const loadCategoryColumns = async () => { + setIsLoading(true); + try { + // table_type_columns에서 input_type='category'인 컬럼 조회 + const response = await apiClient.get( + `/table-management/tables/${tableName}/columns` + ); + + const allColumns = Array.isArray(response.data) + ? response.data + : response.data.data?.columns || []; + + // category 타입만 필터링 + const categoryColumns = allColumns.filter( + (col: any) => + col.inputType === "category" || col.input_type === "category" + ); + + const columnsWithCount = await Promise.all( + categoryColumns.map(async (col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.columnLabel || col.column_label || colName; + + // 각 컬럼의 값 개수 가져오기 (menuId 전달) + let valueCount = 0; + try { + const valuesResult = await getCategoryValues( + tableName, + colName, + menuId, // ← menuId 전달 + false + ); + if (valuesResult.success && valuesResult.data) { + valueCount = valuesResult.data.length; + } + } catch (error) { + console.error(`항목 개수 조회 실패 (${colName}):`, error); + } + + return { + columnName: colName, + columnLabel: colLabel, + inputType: col.inputType || col.input_type, + valueCount, + }; + }) + ); + + setColumns(columnsWithCount); + + // 첫 번째 컬럼 자동 선택 + if (columnsWithCount.length > 0 && !selectedColumn) { + const firstCol = columnsWithCount[0]; + onColumnSelect(firstCol.columnName, firstCol.columnLabel); + } + } catch (error) { + console.error("❌ 카테고리 컬럼 조회 실패:", error); + setColumns([]); + } finally { + setIsLoading(false); + } + }; + + // ... 나머지 렌더링 로직 +} +``` + +### 3. CategoryValueManager 컴포넌트 수정 + +```typescript +// frontend/components/table-category/CategoryValueManager.tsx + +interface CategoryValueManagerProps { + tableName: string; + columnName: string; + menuId: number; // ← 추가 + columnLabel?: string; + onValueCountChange?: (count: number) => void; +} + +export function CategoryValueManager({ + tableName, + columnName, + menuId, // ← 추가 + columnLabel, + onValueCountChange, +}: CategoryValueManagerProps) { + const [values, setValues] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadCategoryValues(); + }, [tableName, columnName, menuId]); // ← menuId 의존성 추가 + + const loadCategoryValues = async () => { + setIsLoading(true); + try { + const response = await getCategoryValues( + tableName, + columnName, + menuId, // ← menuId 전달 + false + ); + + if (response.success && response.data) { + setValues(response.data); + onValueCountChange?.(response.data.length); + } + } catch (error) { + console.error("카테고리 값 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + + const handleAddValue = async (newValue: TableCategoryValue) => { + try { + const response = await addCategoryValue( + { + ...newValue, + tableName, + columnName, + }, + menuId // ← menuId 전달 + ); + + if (response.success) { + loadCategoryValues(); + toast.success("카테고리 값이 추가되었습니다"); + } + } catch (error) { + console.error("카테고리 값 추가 실패:", error); + toast.error("카테고리 값 추가 중 오류가 발생했습니다"); + } + }; + + // ... 나머지 CRUD 로직 (menuId를 항상 포함) +} +``` + +### 4. 화면관리 시스템에서 menuId 전달 + +#### 화면 디자이너에서 menuId 추출 + +```typescript +// frontend/components/screen/ScreenDesigner.tsx + +export function ScreenDesigner() { + const [selectedScreen, setSelectedScreen] = useState(null); + + // 선택된 화면의 menuId 추출 + const currentMenuId = selectedScreen?.menuId; + + // CategoryWidget 렌더링 시 menuId 전달 + return ( +
+ {/* ... */} + +
+ ); +} +``` + +#### CategoryWidget 컴포넌트 (신규 또는 수정) + +```typescript +// frontend/components/screen/widgets/CategoryWidget.tsx + +interface CategoryWidgetProps { + tableName: string; + menuId: number; // ← 추가 +} + +export function CategoryWidget({ tableName, menuId }: CategoryWidgetProps) { + const [selectedColumn, setSelectedColumn] = useState(null); + const [selectedColumnLabel, setSelectedColumnLabel] = useState(""); + + const handleColumnSelect = (columnName: string, columnLabel: string) => { + setSelectedColumn(columnName); + setSelectedColumnLabel(columnLabel); + }; + + return ( +
+ {/* 좌측: 카테고리 컬럼 리스트 */} +
+ +
+ + {/* 우측: 카테고리 값 관리 */} +
+ {selectedColumn ? ( + + ) : ( +
+

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

+
+ )} +
+
+ ); +} +``` + +--- + +## 🔄 기존 데이터 마이그레이션 + +### 마이그레이션 스크립트 + +```sql +-- db/migrations/047_add_menu_id_to_category_values.sql + +-- 1. menu_id 컬럼 추가 (NULL 허용) +ALTER TABLE table_column_category_values +ADD COLUMN IF NOT EXISTS menu_id INTEGER; + +-- 2. 기존 데이터에 임시 menu_id 설정 +-- (관리자가 수동으로 올바른 menu_id로 변경해야 함) +UPDATE table_column_category_values +SET menu_id = 1 +WHERE menu_id IS NULL; + +-- 3. menu_id를 NOT NULL로 변경 +ALTER TABLE table_column_category_values +ALTER COLUMN menu_id SET NOT NULL; + +-- 4. 외래키 추가 +ALTER TABLE table_column_category_values +ADD CONSTRAINT fk_category_value_menu +FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id); + +-- 5. UNIQUE 제약조건 재생성 +ALTER TABLE table_column_category_values +DROP CONSTRAINT IF EXISTS unique_category_value; + +ALTER TABLE table_column_category_values +ADD CONSTRAINT unique_category_value +UNIQUE (table_name, column_name, value_code, menu_id, company_code); + +-- 6. 인덱스 추가 +CREATE INDEX idx_category_value_menu +ON table_column_category_values(menu_id, table_name, column_name, company_code); + +COMMENT ON COLUMN table_column_category_values.menu_id IS '카테고리를 생성한 메뉴 ID (형제 메뉴에서 공유)'; +``` + +--- + +## 📊 사용 시나리오 + +### 시나리오: 영업관리 공통코드 관리 + +#### 1단계: 메뉴 구조 + +``` +영업관리 (parent_id: 0, menu_id: 200) +├── 고객관리 (parent_id: 200, menu_id: 201) - customer_info 테이블 +├── 계약관리 (parent_id: 200, menu_id: 202) - contract_info 테이블 +├── 주문관리 (parent_id: 200, menu_id: 203) - order_info 테이블 +└── 공통코드 관리 (parent_id: 200, menu_id: 204) - 카테고리 관리 전용 +``` + +#### 2단계: 카테고리 관리 화면 생성 + +1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_id: 204) +2. **화면 생성**: 화면관리 시스템에서 화면 생성 +3. **테이블 선택**: 영업관리에서 사용할 **아무 테이블** (예: `customer_info`) + - 테이블 선택은 컬럼 목록을 가져오기 위한 것일 뿐 + - 실제 스코프는 `menu_id`로 결정됨 +4. **위젯 배치**: 카테고리 관리 위젯 드래그앤드롭 + +#### 3단계: 카테고리 값 등록 + +1. **좌측 패널**: `customer_info` 테이블의 카테고리 컬럼 표시 + + - `customer_type` (고객 유형) + - `customer_grade` (고객 등급) + +2. **컬럼 선택**: `customer_type` 클릭 + +3. **우측 패널**: 카테고리 값 관리 + - 추가 버튼 클릭 + - 코드: `REGULAR`, 라벨: `일반 고객` + - 색상: `#3b82f6` + - **저장 시 `menu_id = 204`로 자동 저장됨** + +#### 4단계: 다른 화면에서 사용 + +##### ✅ 형제 메뉴에서 사용 가능 + +**고객관리 화면** (menu_id: 201): + +- `customer_type` 컬럼을 category-select 위젯으로 배치 +- 드롭다운에 `일반 고객`, `VIP 고객` 등 표시됨 ✅ +- **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴 + +**계약관리 화면** (menu_id: 202): + +- `contract_info` 테이블에 `customer_type` 컬럼이 있다면 +- 동일한 카테고리 값 사용 가능 ✅ +- **이유**: 202와 204도 형제 메뉴 + +**주문관리 화면** (menu_id: 203): + +- `order_info` 테이블에 `customer_type` 컬럼이 있다면 +- 동일한 카테고리 값 사용 가능 ✅ +- **이유**: 203과 204도 형제 메뉴 + +##### ❌ 다른 부모 메뉴에서 사용 불가 + +**구매관리 > 발주관리** (parent_id: 300): + +- `purchase_orders` 테이블에 `customer_type` 컬럼이 있어도 +- 영업관리의 카테고리는 표시되지 않음 ❌ +- **이유**: 다른 부모 메뉴이므로 스코프가 다름 +- 구매관리는 자체 카테고리를 별도로 생성해야 함 + +--- + +## 📝 구현 순서 + +### Phase 1: 데이터베이스 마이그레이션 (30분) + +1. ✅ 마이그레이션 파일 작성 (`047_add_menu_id_to_category_values.sql`) +2. ⏳ DB 마이그레이션 실행 +3. ⏳ 기존 데이터 임시 menu_id 설정 (관리자 수동 정리 필요) + +### Phase 2: 백엔드 구현 (2-3시간) + +4. ⏳ `menuService.ts`에 `getSiblingMenuIds()` 함수 추가 +5. ⏳ `tableCategoryValueService.ts`에 menu_id 로직 추가 + - `getCategoryValues()` 메서드에 menuId 파라미터 추가 + - `addCategoryValue()` 메서드에 menuId 파라미터 추가 +6. ⏳ `tableCategoryValueController.ts` 수정 + - 쿼리 파라미터에서 menuId 추출 + - 서비스 호출 시 menuId 전달 +7. ⏳ 백엔드 테스트 + +### Phase 3: 프론트엔드 API 클라이언트 (30분) + +8. ⏳ `frontend/lib/api/tableCategoryValue.ts` 수정 + - `getCategoryValues()` 함수에 menuId 파라미터 추가 + - `addCategoryValue()` 함수에 menuId 파라미터 추가 + +### Phase 4: 프론트엔드 컴포넌트 (2-3시간) + +9. ⏳ `CategoryColumnList.tsx` 수정 + - props에 `menuId` 추가 + - `getCategoryValues()` 호출 시 menuId 전달 +10. ⏳ `CategoryValueManager.tsx` 수정 + - props에 `menuId` 추가 + - 모든 API 호출 시 menuId 전달 +11. ⏳ `CategoryWidget.tsx` 수정 또는 신규 생성 + - `menuId` prop 추가 + - 하위 컴포넌트에 menuId 전달 + +### Phase 5: 화면관리 시스템 통합 (1-2시간) + +12. ⏳ 화면 정보에서 menuId 추출 로직 추가 +13. ⏳ CategoryWidget에 menuId 전달 +14. ⏳ 카테고리 관리 화면 테스트 + +### Phase 6: 테스트 및 문서화 (1시간) + +15. ⏳ 전체 플로우 테스트 +16. ⏳ 메뉴 스코프 동작 검증 +17. ⏳ 사용 가이드 작성 + +--- + +## 🧪 테스트 체크리스트 + +### 백엔드 테스트 + +- [ ] `getSiblingMenuIds()` 함수가 올바른 형제 메뉴 반환 +- [ ] 최상위 메뉴의 경우 자기 자신만 반환 +- [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함 +- [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음 +- [ ] 멀티테넌시 필터링 정상 작동 + +### 프론트엔드 테스트 + +- [ ] 카테고리 컬럼 목록 정상 표시 +- [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함) +- [ ] 카테고리 값 추가 시 menuId 포함 +- [ ] 카테고리 값 수정/삭제 정상 작동 + +### 통합 테스트 + +- [ ] 영업관리 > 공통코드 관리에서 카테고리 생성 +- [ ] 영업관리 > 고객관리에서 카테고리 사용 가능 +- [ ] 영업관리 > 계약관리에서 카테고리 사용 가능 +- [ ] 구매관리에서는 영업관리 카테고리 사용 불가 + +--- + +## 📦 예상 소요 시간 + +| Phase | 작업 내용 | 예상 시간 | +| ---------------- | ------------------- | ------------ | +| Phase 1 | DB 마이그레이션 | 30분 | +| Phase 2 | 백엔드 구현 | 2-3시간 | +| Phase 3 | API 클라이언트 | 30분 | +| Phase 4 | 프론트엔드 컴포넌트 | 2-3시간 | +| Phase 5 | 화면관리 통합 | 1-2시간 | +| Phase 6 | 테스트 및 문서 | 1시간 | +| **총 예상 시간** | | **7-11시간** | + +--- + +## 💡 이점 + +### 1. 메뉴별 독립 관리 + +- 영업관리, 구매관리, 생산관리 등 각 부서별 카테고리 독립 관리 +- 부서 간 카테고리 충돌 방지 + +### 2. 형제 메뉴 간 공유 + +- 같은 부서의 화면들이 카테고리 공유 +- 중복 생성 불필요 + +### 3. 테이블 독립성 + +- 테이블이 달라도 같은 카테고리 사용 가능 +- 테이블 구조 변경에 영향 없음 + +### 4. 직관적인 관리 + +- 메뉴 구조가 곧 카테고리 스코프 +- 이해하기 쉬운 권한 체계 + +--- + +## 🚀 다음 단계 + +### 1. 계획 승인 후 즉시 구현 시작 + +이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다. + +### 2. 채번규칙 시스템도 동일하게 전환 + +카테고리 시스템 전환이 완료되면, 채번규칙 시스템도 동일한 메뉴 기반 스코프로 전환합니다. + +### 3. 공통 유틸리티 함수 재사용 + +`getSiblingMenuIds()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다. + +--- + +이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다. +바로 구현을 시작할까요?