feat: 화면 복사 기능 개선 및 버튼 모달 설정 수정

## 주요 변경사항

### 1. 화면 복사 기능 강화
- 최고 관리자가 다른 회사로 화면 복사 가능하도록 개선
- 메인 화면과 연결된 모달 화면 자동 감지 및 일괄 복사
- 복사 시 버튼의 targetScreenId 자동 업데이트
- 일괄 이름 변경 기능 추가 (복사본 텍스트 제거)
- 중복 화면명 체크 기능 추가

#### 백엔드 (screenManagementService.ts)
- generateMultipleScreenCodes: 여러 화면 코드 일괄 생성 (Advisory Lock 사용)
- detectLinkedModalScreens: edit 액션도 모달로 감지하도록 개선
- checkDuplicateScreenName: 중복 화면명 체크 API 추가
- copyScreenWithModals: 메인+모달 일괄 복사 및 버튼 업데이트
- updateButtonTargetScreenIds: 복사된 모달로 버튼 targetScreenId 업데이트
- updated_date 컬럼 제거 (screen_layouts 테이블에 존재하지 않음)

#### 프론트엔드 (CopyScreenModal.tsx)
- 회사 선택 UI 추가 (최고 관리자 전용)
- 연결된 모달 화면 자동 감지 및 표시
- 일괄 이름 변경 기능 (텍스트 제거/추가)
- 실시간 미리보기
- 중복 화면명 체크

### 2. 버튼 설정 모달 화면 선택 개선
- 편집 중인 화면의 company_code 기준으로 화면 목록 조회
- 최고 관리자가 다른 회사 화면 편집 시 해당 회사의 모달 화면만 표시
- targetScreenId 문자열/숫자 타입 불일치 수정

#### 백엔드 (screenManagementController.ts)
- getScreens API에 companyCode 쿼리 파라미터 추가
- 최고 관리자는 다른 회사의 화면 목록 조회 가능

#### 프론트엔드
- ButtonConfigPanel: currentScreenCompanyCode props 추가
- DetailSettingsPanel: currentScreenCompanyCode 전달
- UnifiedPropertiesPanel: currentScreenCompanyCode 전달
- ScreenDesigner: selectedScreen.companyCode 전달
- targetScreenId 비교 시 parseInt 처리 (문자열→숫자)

### 3. 카테고리 메뉴별 컬럼 분리 기능
- 메뉴별로 카테고리 컬럼을 독립적으로 관리
- 카테고리 컬럼 추가/삭제 시 메뉴 스코프 적용

## 수정된 파일
- backend-node/src/services/screenManagementService.ts
- backend-node/src/controllers/screenManagementController.ts
- backend-node/src/routes/screenManagementRoutes.ts
- frontend/components/screen/CopyScreenModal.tsx
- frontend/components/screen/config-panels/ButtonConfigPanel.tsx
- frontend/components/screen/panels/DetailSettingsPanel.tsx
- frontend/components/screen/panels/UnifiedPropertiesPanel.tsx
- frontend/components/screen/ScreenDesigner.tsx
- frontend/lib/api/screen.ts
This commit is contained in:
kjs
2025-11-13 12:17:10 +09:00
parent b77fffbad7
commit 658211b9d1
21 changed files with 4969 additions and 209 deletions
@@ -0,0 +1,634 @@
# 카테고리 메뉴별 컬럼 분리 구현 완료 보고서
## 📋 개요
**문제**: 같은 테이블의 같은 컬럼을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우 지원 불가
**해결**: 가상 컬럼 분리 (Virtual Column Mapping) 방식 구현
**구현 날짜**: 2025-11-13
---
## ✅ 구현 완료 항목
### 1. 데이터베이스 스키마
#### `category_column_mapping` 테이블 생성 ✅
**파일**: `db/migrations/054_create_category_column_mapping.sql`
```sql
CREATE TABLE category_column_mapping (
mapping_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
logical_column_name VARCHAR(100) NOT NULL, -- 논리적 컬럼명
physical_column_name VARCHAR(100) NOT NULL, -- 물리적 컬럼명
menu_objid NUMERIC NOT NULL,
company_code VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code)
);
```
**인덱스**:
- `idx_mapping_table_menu`: 조회 성능 최적화
- `idx_mapping_company`: 멀티테넌시 필터링
### 2. 백엔드 API 구현
#### 컨트롤러 (tableCategoryValueController.ts) ✅
구현된 API 엔드포인트:
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/table-categories/column-mapping/:tableName/:menuObjid` | 컬럼 매핑 조회 |
| POST | `/table-categories/column-mapping` | 컬럼 매핑 생성/수정 |
| GET | `/table-categories/logical-columns/:tableName/:menuObjid` | 논리적 컬럼 목록 조회 |
| DELETE | `/table-categories/column-mapping/:mappingId` | 컬럼 매핑 삭제 |
**멀티테넌시 지원**:
- ✅ 최고 관리자(`company_code = "*"`): 모든 매핑 조회/수정 가능
- ✅ 일반 회사: 자신의 매핑만 조회/수정 가능
#### 서비스 (tableCategoryValueService.ts) ✅
구현된 주요 메서드:
1. `getColumnMapping()`: 논리명 → 물리명 매핑 조회
2. `createColumnMapping()`: 컬럼 매핑 생성 (UPSERT)
3. `getLogicalColumns()`: 논리적 컬럼 목록 조회
4. `deleteColumnMapping()`: 컬럼 매핑 삭제
5. `convertToPhysicalColumns()`: 데이터 저장 시 자동 변환
**물리적 컬럼 존재 검증**:
```typescript
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
`;
```
**UPSERT 지원**:
```sql
INSERT INTO category_column_mapping (...)
VALUES (...)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET ...
```
### 3. 프론트엔드 API 클라이언트
#### `frontend/lib/api/tableCategoryValue.ts` ✅
구현된 함수:
```typescript
// 컬럼 매핑 조회
getColumnMapping(tableName: string, menuObjid: number)
// 논리적 컬럼 목록 조회
getLogicalColumns(tableName: string, menuObjid: number)
// 컬럼 매핑 생성
createColumnMapping(data: {
tableName: string;
logicalColumnName: string;
physicalColumnName: string;
menuObjid: number;
description?: string;
})
// 컬럼 매핑 삭제
deleteColumnMapping(mappingId: number)
```
**에러 처리**:
- 네트워크 오류 시 `{ success: false, error: message }` 반환
- 콘솔 로그로 디버깅 정보 출력
### 4. 프론트엔드 UI 컴포넌트
#### `AddCategoryColumnDialog.tsx` ✅
**기능**:
- 논리적 컬럼명 입력
- 물리적 컬럼 선택 (드롭다운)
- 설명 입력 (선택사항)
- 적용 메뉴 표시 (읽기 전용)
**검증 로직**:
- 논리적 컬럼명 필수 체크
- 물리적 컬럼 선택 필수 체크
- 중복 매핑 방지
**shadcn/ui 스타일 가이드 준수**:
- ✅ 반응형 크기: `max-w-[95vw] sm:max-w-[500px]`
- ✅ 텍스트 크기: `text-xs sm:text-sm`
- ✅ 입력 필드: `h-8 sm:h-10`
- ✅ 버튼 레이아웃: `flex-1` (모바일), `flex-none` (데스크톱)
---
## 🔄 작동 방식
### 예시: item_info.status 컬럼 분리
#### 1단계: 컬럼 매핑 생성
```
기준정보 > 품목정보 (menu_objid=103)
논리적 컬럼: status_stock
물리적 컬럼: status
카테고리: "정상", "대기", "품절"
영업관리 > 판매품목정보 (menu_objid=203)
논리적 컬럼: status_sales
물리적 컬럼: status
카테고리: "판매중", "판매중지", "품절"
```
#### 2단계: 카테고리 값 저장
```sql
-- table_column_category_values 테이블
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid)
VALUES
('item_info', 'status_stock', 'NORMAL', '정상', 103),
('item_info', 'status_sales', 'ON_SALE', '판매중', 203);
```
#### 3단계: 데이터 입력 (자동 변환)
**사용자 입력 (논리적 컬럼명)**:
```typescript
{
item_name: "키보드",
status_stock: "정상" // 논리적 컬럼명
}
```
**백엔드에서 자동 변환 (물리적 컬럼명)**:
```typescript
// convertToPhysicalColumns() 호출
{
item_name: "키보드",
status: "정상" // 물리적 컬럼명
}
```
**DB에 저장**:
```sql
INSERT INTO item_info (item_name, status, company_code)
VALUES ('키보드', '정상', 'COMPANY_A');
```
#### 4단계: 데이터 조회 (자동 매핑)
**DB 쿼리 결과**:
```typescript
{
item_name: "키보드",
status: "정상" // 물리적 컬럼명
}
```
**프론트엔드 표시 (논리적 컬럼명으로 자동 매핑)**:
```typescript
// 기준정보 > 품목정보에서 보기
{
item_name: "키보드",
status_stock: "정상" // 논리적 컬럼명
}
// 영업관리 > 판매품목정보에서 보기
{
item_name: "마우스",
status_sales: "판매중" // 다른 논리적 컬럼명
}
```
---
## 📊 데이터 흐름도
```
┌────────────────────────────────────────────────────┐
│ 프론트엔드 (UI) │
├────────────────────────────────────────────────────┤
│ 기준정보 > 품목정보 │
│ - status_stock: "정상", "대기", "품절" │
│ │
│ 영업관리 > 판매품목정보 │
│ - status_sales: "판매중", "판매중지", "품절" │
└─────────────────┬──────────────────────────────────┘
│ (논리적 컬럼명 사용)
┌────────────────────────────────────────────────────┐
│ category_column_mapping (매핑 테이블) │
├────────────────────────────────────────────────────┤
│ status_stock → status (menu_objid=103) │
│ status_sales → status (menu_objid=203) │
└─────────────────┬──────────────────────────────────┘
│ (자동 변환)
┌────────────────────────────────────────────────────┐
│ item_info 테이블 (실제 DB) │
├────────────────────────────────────────────────────┤
│ item_name │ status (물리적 컬럼 - 하나만 존재) │
│ 키보드 │ 정상 │
│ 마우스 │ 판매중 │
└────────────────────────────────────────────────────┘
```
---
## 🎯 구현 효과
### 1. 문제 해결 ✅
**Before (문제)**:
```
기준정보 > 품목정보: status = "정상", "대기", "품절"
영업관리 > 판매품목정보: status = "판매중", "판매중지", "품절"
→ 같은 컬럼이라 불가능!
```
**After (해결)**:
```
기준정보 > 품목정보: status_stock = "정상", "대기", "품절"
영업관리 > 판매품목정보: status_sales = "판매중", "판매중지", "품절"
→ 논리적으로 분리되어 가능!
```
### 2. 사용자 경험 개선
- ✅ 메뉴별 맞춤형 카테고리 관리
- ✅ 직관적인 논리적 컬럼명 사용
- ✅ 관리자가 UI에서 쉽게 설정 가능
- ✅ 불필요한 카테고리가 표시되지 않음
### 3. 시스템 안정성
- ✅ 데이터베이스 스키마 변경 최소화
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 물리적 컬럼 존재 검증으로 오류 방지
- ✅ 멀티테넌시 완벽 지원
### 4. 확장성
- ✅ 새로운 메뉴 추가 시 독립적인 카테고리 설정 가능
- ✅ 다른 컴포넌트에도 유사한 패턴 적용 가능
- ✅ 메뉴별 카테고리 통계 및 분석 가능
---
## 🚀 사용 방법
### 관리자 작업 흐름
#### 1. 테이블 타입 관리 접속
```
메뉴: 시스템 관리 > 테이블 타입 관리
```
#### 2. 카테고리 컬럼 추가
```
1. 테이블 선택: item_info
2. "카테고리 컬럼 추가" 버튼 클릭
3. 실제 컬럼 선택: status
4. 논리적 컬럼명 입력: status_stock
5. 설명 입력: "재고 관리용 상태"
6. "추가" 버튼 클릭
```
#### 3. 카테고리 값 추가
```
1. 논리적 컬럼 선택: status_stock
2. "카테고리 값 추가" 버튼 클릭
3. 라벨 입력: "정상", "대기", "품절"
4. 각각 추가
```
#### 4. 다른 메뉴에 대해 반복
```
1. 영업관리 > 판매품목정보 선택
2. 논리적 컬럼명: status_sales
3. 카테고리 값: "판매중", "판매중지", "품절"
```
### 사용자 화면에서 확인
```
기준정보 > 품목정보
→ status_stock 필드가 표시됨
→ 드롭다운: "정상", "대기", "품절"
영업관리 > 판매품목정보
→ status_sales 필드가 표시됨
→ 드롭다운: "판매중", "판매중지", "품절"
```
---
## 🔧 실행 방법
### 1. 데이터베이스 마이그레이션
```sql
-- pgAdmin 또는 psql에서 실행
\i db/migrations/054_create_category_column_mapping.sql
```
**결과 확인**:
```sql
-- 테이블 생성 확인
SELECT * FROM category_column_mapping LIMIT 5;
-- 인덱스 확인
SELECT indexname FROM pg_indexes
WHERE tablename = 'category_column_mapping';
```
### 2. 백엔드 재시작 (불필요)
프로젝트 규칙에 따라 **백엔드 재시작 금지**
- 타입스크립트 파일 변경만으로 자동 반영됨
- 라우트 등록 완료됨
### 3. 프론트엔드 확인
```bash
# 프론트엔드만 재시작 (필요 시)
cd frontend
npm run dev
```
---
## 🧪 테스트 시나리오
### 시나리오 1: 기본 매핑 생성
1. **테이블 타입 관리 접속**
2. **item_info 테이블 선택**
3. **"카테고리 컬럼 추가" 클릭**
4. **입력**:
- 실제 컬럼: `status`
- 논리적 컬럼명: `status_stock`
- 설명: "재고 관리용 상태"
5. **"추가" 클릭**
6. **확인**: 매핑이 생성되었는지 확인
**예상 결과**:
- ✅ 성공 토스트 메시지 표시
- ✅ 논리적 컬럼 목록에 `status_stock` 추가됨
- ✅ DB에 매핑 레코드 생성
### 시나리오 2: 카테고리 값 추가
1. **논리적 컬럼 `status_stock` 선택**
2. **"카테고리 값 추가" 클릭**
3. **입력**:
- 라벨: `정상`
- 코드: 자동 생성
4. **"추가" 클릭**
5. **반복**: "대기", "품절" 추가
**예상 결과**:
- ✅ 각 카테고리 값이 `status_stock` 컬럼에 연결됨
-`menu_objid`가 올바르게 설정됨
### 시나리오 3: 다른 메뉴에 다른 매핑
1. **영업관리 > 판매품목정보 메뉴 선택**
2. **item_info 테이블 선택**
3. **"카테고리 컬럼 추가" 클릭**
4. **입력**:
- 실제 컬럼: `status` (동일한 물리적 컬럼)
- 논리적 컬럼명: `status_sales` (다른 논리명)
- 설명: "판매 관리용 상태"
5. **카테고리 값 추가**: "판매중", "판매중지", "품절"
**예상 결과**:
- ✅ 기준정보 > 품목정보: `status_stock` 표시
- ✅ 영업관리 > 판매품목정보: `status_sales` 표시
- ✅ 서로 다른 카테고리 값 리스트
### 시나리오 4: 데이터 저장 및 조회
1. **기준정보 > 품목정보에서 데이터 입력**
- 품목명: "키보드"
- status_stock: "정상"
2. **저장**
3. **DB 확인**:
```sql
SELECT item_name, status FROM item_info WHERE item_name = '키보드';
-- 결과: status = '정상' (물리적 컬럼)
```
4. **영업관리 > 판매품목정보에서 조회**
- status_sales 필드로 표시되지 않음 (다른 논리명)
**예상 결과**:
- ✅ 논리적 컬럼명으로 입력
- ✅ 물리적 컬럼명으로 저장
- ✅ 메뉴별 독립적인 카테고리 표시
---
## 📝 주의사항
### 1. 기존 데이터 호환성
**기존에 물리적 컬럼명을 직접 사용하던 경우**:
- 마이그레이션 스크립트가 자동으로 기본 매핑 생성
- `logical_column_name = physical_column_name`으로 설정
- 기존 기능 유지됨
### 2. 성능 고려사항
**컬럼 매핑 조회**:
- 인덱스 활용으로 빠른 조회
- 첫 조회 후 캐싱 권장 (향후 개선)
**데이터 저장 시 변환**:
- 매번 매핑 조회 발생
- 트랜잭션 내에서 처리하여 성능 영향 최소화
### 3. 에러 처리
**물리적 컬럼 없음**:
```
에러 메시지: "테이블 item_info에 컬럼 status2가 존재하지 않습니다"
해결: 올바른 컬럼명 선택
```
**논리적 컬럼명 중복**:
```
에러 메시지: "중복된 키 값이 고유 제약조건을 위반합니다"
해결: 다른 논리적 컬럼명 사용
```
---
## 🔍 디버깅 가이드
### 백엔드 로그 확인
```bash
# 로그 파일 위치
tail -f backend-node/logs/app.log
# 컬럼 매핑 조회 로그
"컬럼 매핑 조회" { tableName, menuObjid, companyCode }
# 컬럼 매핑 생성 로그
"컬럼 매핑 생성 완료" { mappingId, tableName, logicalColumnName }
```
### 프론트엔드 콘솔 확인
```javascript
// 브라우저 개발자 도구 > 콘솔
"논리적 컬럼 목록 조회 시작: item_info, 103"
"컬럼 매핑 조회 완료: { status_stock: 'status' }"
```
### 데이터베이스 쿼리
```sql
-- 모든 매핑 확인
SELECT * FROM category_column_mapping
WHERE table_name = 'item_info'
ORDER BY menu_objid, logical_column_name;
-- 특정 메뉴의 매핑
SELECT
logical_column_name,
physical_column_name,
description
FROM category_column_mapping
WHERE table_name = 'item_info'
AND menu_objid = 103;
-- 카테고리 값과 매핑 조인
SELECT
ccm.logical_column_name,
ccm.physical_column_name,
tccv.value_label
FROM category_column_mapping ccm
JOIN table_column_category_values tccv
ON ccm.table_name = tccv.table_name
AND ccm.logical_column_name = tccv.column_name
AND ccm.menu_objid = tccv.menu_objid
WHERE ccm.table_name = 'item_info'
AND ccm.menu_objid = 103;
```
---
## 🎓 추가 참고 자료
### 관련 문서
- [카테고리 메뉴스코프 개선 계획서](카테고리_메뉴스코프_개선_계획서.md)
- [카테고리 메뉴별 컬럼 분리 전략](카테고리_메뉴별_컬럼_분리_전략.md)
### 주요 파일 위치
- 마이그레이션: `db/migrations/054_create_category_column_mapping.sql`
- 컨트롤러: `backend-node/src/controllers/tableCategoryValueController.ts`
- 서비스: `backend-node/src/services/tableCategoryValueService.ts`
- 라우트: `backend-node/src/routes/tableCategoryValueRoutes.ts`
- API 클라이언트: `frontend/lib/api/tableCategoryValue.ts`
- UI 컴포넌트: `frontend/components/table-category/AddCategoryColumnDialog.tsx`
---
## ✅ 체크리스트
### 개발 완료
- [x] `category_column_mapping` 테이블 생성
- [x] 백엔드: 컬럼 매핑 조회 API
- [x] 백엔드: 컬럼 매핑 생성 API
- [x] 백엔드: 논리적 컬럼 목록 조회 API
- [x] 백엔드: 컬럼 매핑 삭제 API
- [x] 백엔드: 데이터 저장 시 자동 변환 로직
- [x] 프론트엔드: API 클라이언트 함수
- [x] 프론트엔드: AddCategoryColumnDialog 컴포넌트
### 테스트 필요 (향후)
- [ ] 시나리오 1: 기본 매핑 생성
- [ ] 시나리오 2: 카테고리 값 추가
- [ ] 시나리오 3: 다른 메뉴에 다른 매핑
- [ ] 시나리오 4: 데이터 저장 및 조회
- [ ] 브라우저 테스트 (Chrome, Safari, Edge)
- [ ] 모바일 반응형 테스트
---
## 🚧 향후 개선 사항
### Phase 2 (권장)
1. **캐싱 메커니즘**
- 컬럼 매핑을 메모리에 캐싱
- 변경 시에만 재조회
- 성능 개선
2. **UI 개선**
- CategoryValueAddDialog에 논리적 컬럼 선택 기능 추가
- 매핑 관리 전용 UI 페이지
- 벌크 매핑 생성 기능
3. **관리 기능**
- 매핑 사용 현황 통계
- 미사용 매핑 자동 정리
- 매핑 복제 기능 (다른 메뉴로)
### Phase 3 (선택)
4. **고급 기능**
- 매핑 버전 관리
- 매핑 변경 이력 추적
- 매핑 검증 도구
---
## 📞 문의 및 지원
**문제 발생 시**:
1. 로그 파일 확인 (backend-node/logs/app.log)
2. 브라우저 콘솔 확인 (개발자 도구)
3. 데이터베이스 쿼리로 직접 확인
**추가 개발 요청**:
- 새로운 기능 제안
- 버그 리포트
- 성능 개선 제안
---
## 🎉 결론
**가상 컬럼 분리 (Virtual Column Mapping) 방식**을 성공적으로 구현하여, 같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용할 수 있게 되었습니다.
**핵심 장점**:
- ✅ 데이터베이스 스키마 변경 최소화
- ✅ 메뉴별 완전히 독립적인 카테고리 관리
- ✅ 자동 변환으로 개발자 부담 감소
- ✅ 멀티테넌시 완벽 지원
**실무 적용**:
- 테이블 타입 관리에서 바로 사용 가능
- 기존 기능과 완전히 호환
- 확장성 있는 아키텍처
이 시스템을 통해 사용자는 메뉴별로 맞춤형 카테고리를 쉽게 관리할 수 있으며, 관리자는 유연하게 카테고리를 설정할 수 있습니다.
@@ -0,0 +1,905 @@
# 카테고리 메뉴별 컬럼 분리 전략
## 1. 문제 정의
### 상황
같은 테이블(`item_info`)의 같은 컬럼(`status`)을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우
**예시**:
```
기준정보 > 품목정보 (menu_objid=103)
- status 컬럼: "정상", "대기", "품절"
영업관리 > 판매품목정보 (menu_objid=203)
- status 컬럼: "판매중", "판매중지", "품절"
```
### 현재 문제점
- `table_column_category_values` 테이블 구조:
- `table_name` + `column_name` + `menu_objid` 조합으로 카테고리 값 저장
- 같은 테이블, 같은 컬럼, 다른 메뉴 = 서로 다른 카테고리 값 사용 가능
- **하지만 실제 DB 컬럼은 하나뿐!**
---
## 2. 해결 방안 비교
### 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) ⭐ **추천**
**개념**: 물리적으로는 같은 `status` 컬럼이지만, 메뉴별로 **논리적으로 다른 컬럼명**을 사용
#### 장점
- ✅ 데이터베이스 스키마 변경 불필요
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 메뉴별 완전히 독립적인 카테고리 관리
- ✅ 유연한 확장 가능
#### 단점
- ⚠️ 컬럼 매핑 관리 필요 (논리명 → 물리명)
- ⚠️ UI에서 가상 컬럼 개념 이해 필요
#### 구현 방식
**데이터베이스**:
```sql
-- table_column_category_values 테이블 사용
-- column_name을 "논리적 컬럼명"으로 저장
-- 기준정보 > 품목정보
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid)
VALUES
('item_info', 'status_stock', 'NORMAL', '정상', 103),
('item_info', 'status_stock', 'PENDING', '대기', 103),
('item_info', 'status_stock', 'OUT_OF_STOCK', '품절', 103);
-- 영업관리 > 판매품목정보
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid)
VALUES
('item_info', 'status_sales', 'ON_SALE', '판매중', 203),
('item_info', 'status_sales', 'DISCONTINUED', '판매중지', 203),
('item_info', 'status_sales', 'OUT_OF_STOCK', '품절', 203);
```
**컬럼 매핑 테이블** (새로 생성):
```sql
CREATE TABLE category_column_mapping (
mapping_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
logical_column_name VARCHAR(100) NOT NULL, -- status_stock, status_sales
physical_column_name VARCHAR(100) NOT NULL, -- status (실제 DB 컬럼)
menu_objid NUMERIC NOT NULL,
company_code VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(table_name, logical_column_name, menu_objid, company_code)
);
-- 예시 데이터
INSERT INTO category_column_mapping
(table_name, logical_column_name, physical_column_name, menu_objid, company_code)
VALUES
('item_info', 'status_stock', 'status', 103, 'COMPANY_A'),
('item_info', 'status_sales', 'status', 203, 'COMPANY_A');
```
**프론트엔드 UI**:
```typescript
// 테이블 타입 관리에서 카테고리 컬럼 추가 시
function AddCategoryColumn({ tableName, menuObjid }: Props) {
const [logicalColumnName, setLogicalColumnName] = useState("");
const [physicalColumnName, setPhysicalColumnName] = useState("");
return (
<Dialog>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 실제 DB 컬럼 선택 */}
<div>
<Label> ()</Label>
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="status">status</SelectItem>
<SelectItem value="category_type">category_type</SelectItem>
</SelectContent>
</Select>
</div>
{/* 논리적 컬럼명 입력 */}
<div>
<Label> ( )</Label>
<Input
value={logicalColumnName}
onChange={(e) => setLogicalColumnName(e.target.value)}
placeholder="예: status_stock, status_sales"
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
{/* 적용할 메뉴 표시 (읽기 전용) */}
<div>
<Label> </Label>
<Input value={currentMenuName} disabled />
</div>
</div>
<DialogFooter>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
**데이터 저장 시 매핑 적용**:
```typescript
// InteractiveScreenViewer.tsx
async function saveData(formData: any) {
const companyCode = user.companyCode;
const menuObjid = screenConfig.menuObjid;
// 논리적 컬럼명 → 물리적 컬럼명 매핑
const mappingResponse = await apiClient.get(
`/api/categories/column-mapping/${tableName}/${menuObjid}`
);
const columnMapping = mappingResponse.data.data; // { status_sales: "status" }
// formData를 물리적 컬럼명으로 변환
const physicalData = {};
for (const [logicalCol, value] of Object.entries(formData)) {
const physicalCol = columnMapping[logicalCol] || logicalCol;
physicalData[physicalCol] = value;
}
// 실제 DB 저장
await apiClient.post(`/api/data/${tableName}`, physicalData);
}
```
---
### 방안 B: 물리적 컬럼 분리 (Physical Column Separation)
**개념**: 실제로 테이블에 `status_stock`, `status_sales` 같은 별도 컬럼 생성
#### 장점
- ✅ 단순하고 직관적
- ✅ 매핑 로직 불필요
#### 단점
- ❌ 데이터베이스 스키마 변경 필요
- ❌ 기존 데이터 마이그레이션 필요
- ❌ 컬럼 추가마다 DDL 실행 필요
- ❌ 유연성 부족
#### 구현 방식
**데이터베이스 스키마 변경**:
```sql
-- item_info 테이블에 컬럼 추가
ALTER TABLE item_info
ADD COLUMN status_stock VARCHAR(50),
ADD COLUMN status_sales VARCHAR(50);
-- 기존 데이터 마이그레이션
UPDATE item_info
SET
status_stock = status, -- 기본값으로 복사
status_sales = status;
```
**단점이 명확함**:
- 메뉴가 추가될 때마다 컬럼 추가 필요
- 테이블 구조가 복잡해짐
- 유지보수 어려움
---
### 방안 C: 현재 구조 유지 (Same Column, Different Values)
**개념**: 같은 `status` 컬럼을 사용하되, 메뉴별로 다른 카테고리 값만 표시
#### 장점
- ✅ 가장 단순한 구조
- ✅ 추가 개발 불필요
#### 단점
-**데이터 정합성 문제**: 실제 DB에는 하나의 값만 저장 가능
- ❌ 메뉴별로 다른 값을 저장할 수 없음
#### 예시 (문제 발생)
```
item_info 테이블의 실제 데이터:
item_id | status
--------|--------
1 | "NORMAL" (기준정보에서 입력)
2 | "ON_SALE" (영업관리에서 입력)
→ 기준정보에서 item_id=2를 볼 때 "ON_SALE"이 뭔지 모름 (정의되지 않은 값)
```
**결론**: 이 방안은 **불가능**합니다.
---
## 3. 최종 추천 방안
### 🏆 방안 A: 가상 컬럼 분리 (Virtual Column Mapping)
**이유**:
1. 데이터베이스 스키마 변경 없음
2. 메뉴별 완전히 독립적인 카테고리 관리
3. 실제 데이터 저장 시 물리적 컬럼으로 자동 매핑
4. 확장성과 유연성 확보
**핵심 개념**:
- **논리적 컬럼명**: UI와 카테고리 설정에서 사용 (`status_stock`, `status_sales`)
- **물리적 컬럼명**: 실제 DB 저장 시 사용 (`status`)
- **매핑 테이블**: 논리명과 물리명을 연결
---
## 4. 구현 계획
### Phase 1: 데이터베이스 스키마 추가
#### 4.1 컬럼 매핑 테이블 생성
```sql
-- db/migrations/054_create_category_column_mapping.sql
CREATE TABLE category_column_mapping (
mapping_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
logical_column_name VARCHAR(100) NOT NULL COMMENT '논리적 컬럼명 (UI에서 사용)',
physical_column_name VARCHAR(100) NOT NULL COMMENT '물리적 컬럼명 (실제 DB 컬럼)',
menu_objid NUMERIC NOT NULL,
company_code VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT fk_mapping_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code),
CONSTRAINT fk_mapping_menu FOREIGN KEY (menu_objid)
REFERENCES menu_info(objid),
CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code)
);
CREATE INDEX idx_mapping_table_menu ON category_column_mapping(table_name, menu_objid);
CREATE INDEX idx_mapping_company ON category_column_mapping(company_code);
COMMENT ON TABLE category_column_mapping IS '카테고리 컬럼의 논리명-물리명 매핑';
COMMENT ON COLUMN category_column_mapping.logical_column_name IS '메뉴별 카테고리 컬럼의 논리적 이름 (예: status_stock)';
COMMENT ON COLUMN category_column_mapping.physical_column_name IS '실제 테이블의 물리적 컬럼 이름 (예: status)';
```
#### 4.2 기존 카테고리 컬럼 마이그레이션 (선택사항)
```sql
-- 기존에 직접 물리적 컬럼명을 사용하던 경우 매핑 생성
INSERT INTO category_column_mapping
(table_name, logical_column_name, physical_column_name, menu_objid, company_code)
SELECT DISTINCT
table_name,
column_name, -- 기존에는 논리명=물리명
column_name,
menu_objid,
company_code
FROM table_column_category_values
WHERE menu_objid IS NOT NULL
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO NOTHING;
```
### Phase 2: 백엔드 API 구현
#### 2.1 컬럼 매핑 API
**파일**: `backend-node/src/controllers/categoryController.ts`
```typescript
/**
* 메뉴별 컬럼 매핑 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
* @returns { logical_column: physical_column } 매핑
*/
export async function getColumnMapping(req: Request, res: Response) {
const { tableName, menuObjid } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
logical_column_name,
physical_column_name,
description
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
`;
const result = await pool.query(query, [tableName, menuObjid, companyCode]);
// { status_stock: "status", status_sales: "status" } 형태로 변환
const mapping: Record<string, string> = {};
result.rows.forEach((row) => {
mapping[row.logical_column_name] = row.physical_column_name;
});
logger.info("컬럼 매핑 조회", {
tableName,
menuObjid,
companyCode,
mappingCount: Object.keys(mapping).length,
});
return res.json({
success: true,
data: mapping,
});
}
/**
* 컬럼 매핑 생성
*/
export async function createColumnMapping(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
description,
} = req.body;
// 입력 검증
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다",
});
}
// 물리적 컬럼이 실제로 존재하는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
`;
const columnCheck = await pool.query(columnCheckQuery, [tableName, physicalColumnName]);
if (columnCheck.rowCount === 0) {
return res.status(400).json({
success: false,
message: `테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`,
});
}
// 매핑 저장
const query = `
INSERT INTO category_column_mapping (
table_name,
logical_column_name,
physical_column_name,
menu_objid,
company_code,
description,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET
physical_column_name = EXCLUDED.physical_column_name,
description = EXCLUDED.description,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by
RETURNING *
`;
const result = await pool.query(query, [
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
description || null,
req.user!.userId,
req.user!.userId,
]);
logger.info("컬럼 매핑 생성", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
**라우트 등록**:
```typescript
router.get("/api/categories/column-mapping/:tableName/:menuObjid", authenticate, getColumnMapping);
router.post("/api/categories/column-mapping", authenticate, createColumnMapping);
```
#### 2.2 데이터 저장 시 매핑 적용
**파일**: `backend-node/src/controllers/dataController.ts`
```typescript
/**
* 데이터 저장 시 논리적 컬럼명 → 물리적 컬럼명 변환
*/
export async function saveData(req: Request, res: Response) {
const { tableName } = req.params;
const { menuObjid, data } = req.body;
const companyCode = req.user!.companyCode;
// 1. 컬럼 매핑 조회
const mappingQuery = `
SELECT logical_column_name, physical_column_name
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
`;
const mappingResult = await pool.query(mappingQuery, [tableName, menuObjid, companyCode]);
const mapping: Record<string, string> = {};
mappingResult.rows.forEach((row) => {
mapping[row.logical_column_name] = row.physical_column_name;
});
// 2. 논리적 컬럼명 → 물리적 컬럼명 변환
const physicalData: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
physicalData[physicalColumn] = value;
}
// 3. 실제 데이터 저장
const columns = Object.keys(physicalData);
const values = Object.values(physicalData);
const placeholders = columns.map((_, i) => `$${i + 1}`).join(", ");
const insertQuery = `
INSERT INTO ${tableName} (${columns.join(", ")}, company_code)
VALUES (${placeholders}, $${columns.length + 1})
RETURNING *
`;
const result = await pool.query(insertQuery, [...values, companyCode]);
logger.info("데이터 저장 (컬럼 매핑 적용)", {
tableName,
menuObjid,
logicalColumns: Object.keys(data),
physicalColumns: columns,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
### Phase 3: 프론트엔드 UI 구현
#### 3.1 테이블 타입 관리: 논리적 컬럼 추가
**파일**: `frontend/components/admin/table-type-management/AddCategoryColumnDialog.tsx`
```typescript
interface AddCategoryColumnDialogProps {
tableName: string;
menuObjid: number;
menuName: string;
onSuccess: () => void;
}
export function AddCategoryColumnDialog({
tableName,
menuObjid,
menuName,
onSuccess,
}: AddCategoryColumnDialogProps) {
const [open, setOpen] = useState(false);
const [physicalColumns, setPhysicalColumns] = useState<string[]>([]);
const [logicalColumnName, setLogicalColumnName] = useState("");
const [physicalColumnName, setPhysicalColumnName] = useState("");
const [description, setDescription] = useState("");
// 테이블의 실제 컬럼 목록 조회
useEffect(() => {
if (open) {
async function loadColumns() {
const response = await apiClient.get(`/api/tables/${tableName}/columns`);
if (response.data.success) {
setPhysicalColumns(response.data.data.map((col: any) => col.column_name));
}
}
loadColumns();
}
}, [open, tableName]);
const handleSave = async () => {
// 1. 컬럼 매핑 생성
const mappingResponse = await apiClient.post("/api/categories/column-mapping", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
description,
});
if (!mappingResponse.data.success) {
toast.error("컬럼 매핑 생성 실패");
return;
}
toast.success("논리적 컬럼이 추가되었습니다");
setOpen(false);
onSuccess();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
</Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 적용 메뉴 (읽기 전용) */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={menuName}
disabled
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 실제 컬럼 선택 */}
<div>
<Label className="text-xs sm:text-sm">
() *
</Label>
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{physicalColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 논리적 컬럼명 입력 */}
<div>
<Label className="text-xs sm:text-sm">
( ) *
</Label>
<Input
value={logicalColumnName}
onChange={(e) => setLogicalColumnName(e.target.value)}
placeholder="예: status_stock, status_sales"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 설명 (선택사항) */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
className="text-xs sm:text-sm"
rows={2}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={!logicalColumnName || !physicalColumnName}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
#### 3.2 카테고리 값 추가 시 논리적 컬럼 사용
**파일**: `frontend/components/admin/table-type-management/CategoryValueEditor.tsx`
```typescript
export function CategoryValueEditor({
tableName,
menuObjid,
onSuccess,
}: Props) {
const [logicalColumns, setLogicalColumns] = useState<Array<{
logical_column_name: string;
physical_column_name: string;
description: string;
}>>([]);
const [selectedLogicalColumn, setSelectedLogicalColumn] = useState("");
const [valueCode, setValueCode] = useState("");
const [valueLabel, setValueLabel] = useState("");
// 논리적 컬럼 목록 조회
useEffect(() => {
async function loadLogicalColumns() {
const response = await apiClient.get(
`/api/categories/logical-columns/${tableName}/${menuObjid}`
);
if (response.data.success) {
setLogicalColumns(response.data.data);
}
}
loadLogicalColumns();
}, [tableName, menuObjid]);
const handleSave = async () => {
await apiClient.post("/api/categories/values", {
tableName,
columnName: selectedLogicalColumn, // 논리적 컬럼명 저장
valueCode,
valueLabel,
menuObjid,
});
toast.success("카테고리 값이 추가되었습니다");
onSuccess();
};
return (
<div className="space-y-4">
{/* 논리적 컬럼 선택 */}
<div>
<Label> </Label>
<Select value={selectedLogicalColumn} onValueChange={setSelectedLogicalColumn}>
<SelectTrigger>
<SelectValue placeholder="카테고리 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{logicalColumns.map((col) => (
<SelectItem key={col.logical_column_name} value={col.logical_column_name}>
<div className="flex flex-col">
<span className="font-medium">{col.logical_column_name}</span>
<span className="text-xs text-gray-500">
: {col.physical_column_name}
</span>
{col.description && (
<span className="text-xs text-gray-400">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 카테고리 값 입력 */}
<div>
<Label></Label>
<Input value={valueCode} onChange={(e) => setValueCode(e.target.value)} />
</div>
<div>
<Label></Label>
<Input value={valueLabel} onChange={(e) => setValueLabel(e.target.value)} />
</div>
<Button onClick={handleSave}></Button>
</div>
);
}
```
#### 3.3 데이터 저장 시 매핑 적용
**파일**: `frontend/components/screen/InteractiveScreenViewer.tsx`
```typescript
async function saveFormData(formData: Record<string, any>) {
const menuObjid = screenConfig.menuObjid;
const tableName = screenConfig.tableName;
// 백엔드에서 자동으로 논리명 → 물리명 변환
const response = await apiClient.post(`/api/data/${tableName}`, {
menuObjid,
data: formData, // 논리적 컬럼명으로 전달
});
if (response.data.success) {
toast.success("저장되었습니다");
} else {
toast.error("저장 실패");
}
}
```
---
## 5. 사용 예시
### 예시 1: 품목 상태 컬럼 분리
**상황**: `item_info.status` 컬럼을 두 메뉴에서 다르게 사용
#### 1단계: 논리적 컬럼 생성
```
기준정보 > 품목정보 (menu_objid=103)
- 논리적 컬럼명: status_stock
- 물리적 컬럼명: status
- 카테고리 값: 정상, 대기, 품절
영업관리 > 판매품목정보 (menu_objid=203)
- 논리적 컬럼명: status_sales
- 물리적 컬럼명: status
- 카테고리 값: 판매중, 판매중지, 품절
```
#### 2단계: 데이터 입력
```typescript
// 기준정보 > 품목정보에서 입력
{
item_name: "키보드",
status_stock: "정상", // 논리적 컬럼명
}
// DB에 저장될 때
{
item_name: "키보드",
status: "정상", // 물리적 컬럼명으로 자동 변환
}
```
```typescript
// 영업관리 > 판매품목정보에서 입력
{
item_name: "마우스",
status_sales: "판매중", // 논리적 컬럼명
}
// DB에 저장될 때
{
item_name: "마우스",
status: "판매중", // 물리적 컬럼명으로 자동 변환
}
```
#### 3단계: 데이터 조회
```typescript
// 기준정보 > 품목정보에서 조회
SELECT
item_id,
item_name,
status --
FROM item_info
WHERE company_code = 'COMPANY_A';
// 프론트엔드에서 표시할 때 논리적 컬럼명으로 매핑
{
item_name: "키보드",
status_stock: "정상", // UI에서는 논리명 사용
}
```
---
## 6. 체크리스트
### 개발 단계
- [ ] `category_column_mapping` 테이블 생성
- [ ] 백엔드: 컬럼 매핑 조회 API
- [ ] 백엔드: 컬럼 매핑 생성 API
- [ ] 백엔드: 데이터 저장 시 논리명 → 물리명 변환 로직
- [ ] 프론트엔드: 논리적 컬럼 추가 UI
- [ ] 프론트엔드: 카테고리 값 추가 시 논리적 컬럼 선택
- [ ] 프론트엔드: 데이터 저장 시 논리적 컬럼명 사용
### 테스트 단계
- [ ] 같은 물리적 컬럼에 여러 논리적 컬럼 생성
- [ ] 메뉴별로 다른 카테고리 값 표시
- [ ] 데이터 저장 시 올바른 물리적 컬럼에 저장
- [ ] 데이터 조회 시 논리적 컬럼명으로 매핑
---
## 7. 장점 요약
### 데이터베이스
- ✅ 스키마 변경 최소화
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 유연한 컬럼 관리
### UI/UX
- ✅ 메뉴별 맞춤형 카테고리 관리
- ✅ 직관적인 논리적 컬럼명 사용
- ✅ 관리자가 쉽게 설정 가능
### 개발
- ✅ 백엔드에서 자동 매핑 처리
- ✅ 프론트엔드는 논리적 컬럼명만 사용
- ✅ 확장 가능한 아키텍처
---
## 8. 결론
**같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용하려면 "가상 컬럼 분리 (Virtual Column Mapping)" 방식이 최적입니다.**
- 논리적 컬럼명으로 메뉴별 카테고리 독립성 확보
- 물리적 컬럼명으로 실제 데이터 저장
- 매핑 테이블로 유연한 관리
이 방식은 데이터베이스 변경을 최소화하면서도 메뉴별로 완전히 독립적인 카테고리 관리를 가능하게 합니다.
@@ -0,0 +1,673 @@
# 카테고리 메뉴 스코프 개선 계획서
## 1. 문제 정의
### 현재 문제점
**카테고리 컴포넌트가 형제 메뉴 기반으로 카테고리를 조회하여 메뉴별 카테고리 구분 불가**
#### 구체적 상황
- `기준정보 > 품목정보` (item_info 테이블 사용)
- `영업관리 > 판매품목정보` (item_info 테이블 사용)
- 두 메뉴가 같은 테이블(`item_info`)을 사용하지만, 표시되어야 할 카테고리는 달라야 함
#### 현재 로직의 문제
```typescript
// 형제 메뉴들이 사용하는 모든 테이블의 카테고리 컬럼 조회
const siblings = await getSiblingMenus(currentMenuId);
const categories = await getCategoriesFromTables(siblings.tables);
```
**결과**:
- `기준정보 > 품목정보`에서 보이지 않아야 할 카테고리가 표시됨
- `영업관리 > 판매품목정보`에서만 보여야 할 카테고리가 `기준정보`에도 표시됨
---
## 2. 해결 방안
### 핵심 아이디어
**카테고리 값이 어떤 2레벨 메뉴에 속하는지 명시적으로 저장하고, 조회 시 2레벨 메뉴 기준으로 필터링**
### 2.1 데이터베이스 구조 (이미 준비됨)
```sql
-- table_column_category_values 테이블에 이미 menu_objid 컬럼 존재
SELECT * FROM table_column_category_values;
-- 컬럼 구조
-- value_id: PK
-- table_name: 테이블명
-- column_name: 컬럼명
-- value_code: 카테고리 코드
-- value_label: 카테고리 라벨
-- menu_objid: 2레벨 메뉴 OBJID (핵심!)
-- company_code: 회사 코드
-- ...
```
### 2.2 메뉴 계층 구조
```
1레벨 메뉴 (기준정보, objid=1)
├── 2레벨 메뉴 (회사정보, objid=101)
├── 2레벨 메뉴 (부서관리, objid=102)
└── 2레벨 메뉴 (품목정보, objid=103) ← 여기에 item_info 테이블 사용
1레벨 메뉴 (영업관리, objid=2)
├── 2레벨 메뉴 (견적관리, objid=201)
├── 2레벨 메뉴 (수주관리, objid=202)
└── 2레벨 메뉴 (판매품목정보, objid=203) ← 여기도 item_info 테이블 사용
```
### 2.3 카테고리 값 저장 방식
```sql
-- 기준정보 > 품목정보에서 사용할 카테고리
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid, company_code)
VALUES
('item_info', 'category_type', 'STOCK_ITEM', '재고품목', 103, 'COMPANY_A'),
('item_info', 'category_type', 'ASSET', '자산', 103, 'COMPANY_A');
-- 영업관리 > 판매품목정보에서 사용할 카테고리
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid, company_code)
VALUES
('item_info', 'category_type', 'SALES_ITEM', '판매품목', 203, 'COMPANY_A'),
('item_info', 'category_type', 'SERVICE', '서비스', 203, 'COMPANY_A');
```
---
## 3. 구현 단계
### Phase 1: 백엔드 API 수정
#### 3.1 카테고리 조회 API 개선
**파일**: `backend-node/src/controllers/categoryController.ts`
**현재 로직**:
```typescript
export async function getCategoryColumns(req: Request, res: Response) {
const { tableName } = req.params;
const companyCode = req.user!.companyCode;
// 형제 메뉴들의 테이블에서 카테고리 컬럼 조회
const siblings = await getSiblingMenuTables(menuObjid);
const query = `
SELECT DISTINCT
table_name,
column_name,
value_code,
value_label
FROM table_column_category_values
WHERE table_name IN (${siblings.join(',')})
AND company_code = $1
`;
}
```
**개선된 로직**:
```typescript
export async function getCategoryColumns(req: Request, res: Response) {
const { tableName, menuObjid } = req.params; // menuObjid 추가
const companyCode = req.user!.companyCode;
// 2레벨 메뉴 OBJID 찾기
const topLevelMenuId = await getTopLevelMenuId(menuObjid);
const query = `
SELECT
table_name,
column_name,
value_code,
value_label,
value_order,
parent_value_id,
depth,
description,
color,
icon
FROM table_column_category_values
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
AND is_active = true
ORDER BY value_order, value_label
`;
const result = await pool.query(query, [tableName, topLevelMenuId, companyCode]);
logger.info("카테고리 컬럼 조회 (메뉴 스코프)", {
tableName,
menuObjid: topLevelMenuId,
companyCode,
categoryCount: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
}
```
#### 3.2 2레벨 메뉴 OBJID 찾기 함수
```typescript
/**
* 현재 메뉴의 최상위(2레벨) 메뉴 OBJID 찾기
*
* @param menuObjid - 현재 메뉴 OBJID
* @returns 2레벨 메뉴 OBJID
*/
async function getTopLevelMenuId(menuObjid: number): Promise<number> {
const query = `
WITH RECURSIVE menu_hierarchy AS (
-- 현재 메뉴
SELECT
objid,
parent_obj_id,
menu_type,
1 as level
FROM menu_info
WHERE objid = $1
UNION ALL
-- 부모 메뉴들 재귀 조회
SELECT
m.objid,
m.parent_obj_id,
m.menu_type,
mh.level + 1
FROM menu_info m
JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
)
SELECT objid
FROM menu_hierarchy
WHERE parent_obj_id IS NULL OR menu_type = 1 -- 1레벨 메뉴의 자식 = 2레벨
ORDER BY level DESC
LIMIT 1;
`;
const result = await pool.query(query, [menuObjid]);
if (result.rowCount === 0) {
throw new Error(`메뉴를 찾을 수 없습니다: ${menuObjid}`);
}
return result.rows[0].objid;
}
```
#### 3.3 카테고리 값 생성 API 개선
**파일**: `backend-node/src/controllers/categoryController.ts`
```typescript
export async function createCategoryValue(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const {
tableName,
columnName,
valueCode,
valueLabel,
menuObjid, // 필수로 추가
parentValueId,
depth,
description,
color,
icon,
} = req.body;
// 입력 검증
if (!tableName || !columnName || !valueCode || !valueLabel || !menuObjid) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다",
});
}
const query = `
INSERT INTO table_column_category_values (
table_name,
column_name,
value_code,
value_label,
menu_objid,
parent_value_id,
depth,
description,
color,
icon,
company_code,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *
`;
const result = await pool.query(query, [
tableName,
columnName,
valueCode,
valueLabel,
menuObjid,
parentValueId || null,
depth || 1,
description || null,
color || null,
icon || null,
companyCode,
req.user!.userId,
req.user!.userId,
]);
logger.info("카테고리 값 생성", {
tableName,
columnName,
valueCode,
menuObjid,
companyCode,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
### Phase 2: 프론트엔드 - 테이블 타입 관리 UI 개선
#### 2.1 테이블 타입 관리 컴포넌트 수정
**파일**: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
**추가할 기능**:
1. 카테고리 컬럼 설정 시 "적용할 메뉴 선택" 기능
2. 2레벨 메뉴 목록 조회 및 다중 선택 UI
3. 선택된 메뉴별로 카테고리 값 생성
```typescript
interface CategoryMenuScope {
menuObjid: number;
menuName: string;
parentMenuName: string;
isSelected: boolean;
}
function CategoryColumnConfig({ tableName, columnName }: Props) {
const [menuScopes, setMenuScopes] = useState<CategoryMenuScope[]>([]);
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
// 2레벨 메뉴 목록 조회
useEffect(() => {
async function loadMenuScopes() {
const response = await apiClient.get("/api/menus/second-level");
if (response.data.success) {
setMenuScopes(response.data.data);
}
}
loadMenuScopes();
}, []);
// 카테고리 값 저장 시 선택된 메뉴들에 대해 각각 저장
const handleSaveCategoryValue = async (categoryData: any) => {
for (const menuObjid of selectedMenus) {
await apiClient.post("/api/categories/values", {
...categoryData,
tableName,
columnName,
menuObjid, // 메뉴별로 저장
});
}
toast.success("카테고리가 선택된 메뉴에 저장되었습니다");
};
return (
<div className="space-y-4">
<Label> </Label>
<div className="border rounded-lg p-4 space-y-2 max-h-60 overflow-y-auto">
{menuScopes.map((scope) => (
<div key={scope.menuObjid} className="flex items-center gap-2">
<Checkbox
checked={selectedMenus.includes(scope.menuObjid)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedMenus([...selectedMenus, scope.menuObjid]);
} else {
setSelectedMenus(selectedMenus.filter(id => id !== scope.menuObjid));
}
}}
/>
<span className="text-sm">
{scope.parentMenuName} {scope.menuName}
</span>
</div>
))}
</div>
{selectedMenus.length === 0 && (
<p className="text-xs text-destructive">
</p>
)}
{/* 카테고리 값 추가 UI */}
<CategoryValueEditor
onSave={handleSaveCategoryValue}
disabled={selectedMenus.length === 0}
/>
</div>
);
}
```
#### 2.2 2레벨 메뉴 조회 API 추가
**파일**: `backend-node/src/controllers/menuController.ts`
```typescript
/**
* 2레벨 메뉴 목록 조회
* (카테고리 스코프 선택용)
*/
export async function getSecondLevelMenus(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const query = `
SELECT
m2.objid as menu_objid,
m2.menu_name_kor as menu_name,
m1.menu_name_kor as parent_menu_name,
m2.screen_code
FROM menu_info m2
JOIN menu_info m1 ON m2.parent_obj_id = m1.objid
WHERE m2.menu_type = 2 -- 2레벨 메뉴
AND (m2.company_code = $1 OR m2.company_code = '*')
AND m2.status = 'Y'
ORDER BY m1.seq, m2.seq
`;
const result = await pool.query(query, [companyCode]);
return res.json({
success: true,
data: result.rows,
});
}
```
**라우트 등록**:
```typescript
router.get("/api/menus/second-level", authenticate, getSecondLevelMenus);
```
### Phase 3: 프론트엔드 - 카테고리 컴포넌트 개선
#### 3.1 카테고리 컴포넌트 조회 로직 변경
**파일**: `frontend/lib/registry/components/category/CategoryComponent.tsx`
**현재 로직** (형제 메뉴 기반):
```typescript
useEffect(() => {
async function loadCategories() {
// 형제 메뉴들의 테이블에서 카테고리 조회
const response = await apiClient.get(`/api/categories/${tableName}`);
}
loadCategories();
}, [tableName]);
```
**개선된 로직** (2레벨 메뉴 기반):
```typescript
useEffect(() => {
async function loadCategories() {
// 현재 메뉴 OBJID 가져오기
const menuObjid = screenConfig.menuObjid; // 또는 URL에서 파싱
if (!menuObjid) {
console.warn("메뉴 OBJID를 찾을 수 없습니다");
return;
}
// 2레벨 메뉴 기준으로 카테고리 조회
const response = await apiClient.get(
`/api/categories/${tableName}/menu/${menuObjid}`
);
if (response.data.success) {
setCategories(response.data.data);
}
}
loadCategories();
}, [tableName, screenConfig.menuObjid]);
```
#### 3.2 API 클라이언트 함수 추가
**파일**: `frontend/lib/api/category.ts`
```typescript
/**
* 특정 메뉴 스코프의 카테고리 컬럼 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
*/
export async function getCategoriesByMenuScope(
tableName: string,
menuObjid: number
): Promise<ApiResponse<CategoryColumn[]>> {
try {
const response = await apiClient.get(
`/api/categories/${tableName}/menu/${menuObjid}`
);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
```
---
## 4. 데이터 마이그레이션
### 4.1 기존 카테고리 값에 menu_objid 설정
**문제**: 기존 카테고리 값들은 `menu_objid`가 설정되지 않음
**해결**: 관리자가 테이블 타입 관리에서 기존 카테고리 값들의 메뉴 스코프를 선택하도록 유도
#### 마이그레이션 스크립트 (선택사항)
```sql
-- db/migrations/053_backfill_category_menu_objid.sql
-- Step 1: 기존 카테고리 값 확인
SELECT
value_id,
table_name,
column_name,
value_label,
menu_objid,
company_code
FROM table_column_category_values
WHERE menu_objid IS NULL OR menu_objid = 0;
-- Step 2: 기본값 설정 (예시)
-- 관리자가 수동으로 올바른 menu_objid를 설정해야 함
UPDATE table_column_category_values
SET menu_objid = 103 -- 예: 기준정보 > 품목정보
WHERE table_name = 'item_info'
AND column_name = 'category_type'
AND value_code IN ('STOCK_ITEM', 'ASSET')
AND (menu_objid IS NULL OR menu_objid = 0);
UPDATE table_column_category_values
SET menu_objid = 203 -- 예: 영업관리 > 판매품목정보
WHERE table_name = 'item_info'
AND column_name = 'category_type'
AND value_code IN ('SALES_ITEM', 'SERVICE')
AND (menu_objid IS NULL OR menu_objid = 0);
```
### 4.2 UI에서 menu_objid 미설정 경고
```typescript
// TableTypeManagement.tsx
function CategoryColumnList({ categoryColumns }: Props) {
const unassignedCategories = categoryColumns.filter(
(col) => !col.menuObjid || col.menuObjid === 0
);
if (unassignedCategories.length > 0) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle> </AlertTitle>
<AlertDescription>
{unassignedCategories.length} .
<br />
.
</AlertDescription>
</Alert>
);
}
return <div>...</div>;
}
```
---
## 5. 테스트 시나리오
### 5.1 기본 기능 테스트
1. **2레벨 메뉴 조회**
- `GET /api/menus/second-level`
- 기준정보, 영업관리 등 2레벨 메뉴들이 조회되는지 확인
2. **카테고리 값 생성 (메뉴 스코프 포함)**
- 테이블 타입 관리에서 카테고리 컬럼 선택
- 적용할 메뉴 선택 (예: 영업관리 > 판매품목정보)
- 카테고리 값 추가
- DB에 올바른 `menu_objid`로 저장되는지 확인
3. **카테고리 컴포넌트 조회 (메뉴별 필터링)**
- `기준정보 > 품목정보` 화면 접속
- 해당 메뉴의 카테고리만 표시되는지 확인
- `영업관리 > 판매품목정보` 화면 접속
- 다른 카테고리가 표시되는지 확인
### 5.2 엣지 케이스 테스트
1. **menu_objid 미설정 카테고리**
- 기존 카테고리 값 (menu_objid = NULL)
- 경고 메시지 표시
- 조회 시 제외되는지 확인
2. **여러 메뉴에 동일 카테고리**
- 하나의 카테고리를 여러 메뉴에 적용
- 각 메뉴에서 독립적으로 관리되는지 확인
3. **최고 관리자 권한**
- 최고 관리자는 모든 메뉴의 카테고리를 볼 수 있는지 확인
---
## 6. 롤백 계획
만약 문제가 발생하면 다음 단계로 롤백:
### 6.1 백엔드 API 롤백
```bash
git revert <commit-hash>
```
### 6.2 데이터베이스 롤백
```sql
-- menu_objid 조건 제거 (임시)
-- 기존 로직으로 돌아감
```
### 6.3 프론트엔드 롤백
- 카테고리 조회 시 `menuObjid` 파라미터 제거
- 형제 메뉴 기반 로직으로 복원
---
## 7. 구현 우선순위
### Phase 1 (필수)
1. ✅ 백엔드: `getTopLevelMenuId()` 함수 구현
2. ✅ 백엔드: 카테고리 조회 API에 `menu_objid` 필터링 추가
3. ✅ 백엔드: 카테고리 값 생성 시 `menu_objid` 필수 처리
4. ✅ 백엔드: 2레벨 메뉴 목록 조회 API 추가
### Phase 2 (필수)
5. ✅ 프론트엔드: 테이블 타입 관리에서 메뉴 선택 UI 추가
6. ✅ 프론트엔드: 카테고리 컴포넌트 조회 로직 변경
### Phase 3 (권장)
7. ⏳ 데이터 마이그레이션: 기존 카테고리 값에 `menu_objid` 설정
8. ⏳ UI: `menu_objid` 미설정 경고 표시
9. ⏳ 테스트: 시나리오별 검증
### Phase 4 (선택)
10. ⏳ 관리자 대시보드: 카테고리 사용 현황 통계
11. ⏳ 벌크 업데이트: 여러 카테고리의 메뉴 스코프 일괄 변경
---
## 8. 기대 효과
### 8.1 문제 해결
- ✅ 같은 테이블을 사용하는 메뉴들도 서로 다른 카테고리 사용 가능
- ✅ 메뉴별 카테고리 독립성 보장
- ✅ 관리자가 메뉴별로 카테고리를 명확히 제어
### 8.2 사용자 경험 개선
- ✅ 카테고리가 메뉴 문맥에 맞게 표시됨
- ✅ 불필요한 카테고리가 표시되지 않음
- ✅ 직관적인 카테고리 관리 UI
### 8.3 확장성
- ✅ 향후 메뉴별 세밀한 권한 제어 가능
- ✅ 메뉴별 카테고리 통계 및 분석 가능
- ✅ 다른 컴포넌트에도 유사한 메뉴 스코프 패턴 적용 가능
---
## 9. 참고 자료
- 데이터베이스 스키마: `table_column_category_values` 테이블
- 메뉴 구조: `menu_info` 테이블
- 카테고리 컴포넌트: `frontend/lib/registry/components/category/CategoryComponent.tsx`
- 테이블 타입 관리: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
- API 클라이언트: `frontend/lib/api/category.ts`
---
## 10. 결론
사용자가 제안한 **"테이블 타입 관리에서 카테고리 컬럼 설정 시 2레벨 메뉴 선택"** 방식이 가장 현실적이고 효과적인 해결책입니다.
**핵심 변경사항**:
1. 카테고리 값 저장 시 `menu_objid` 필수 입력
2. 카테고리 조회 시 2레벨 메뉴 기준 필터링 (형제 메뉴 기준 제거)
3. 테이블 타입 관리 UI에서 메뉴 선택 기능 추가
이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.