feat: 메뉴 복사 기능 - 2단계 복사 방식으로 화면 참조 매핑 문제 해결
- 문제: 화면 복사 시 참조되는 화면이 아직 복사되지 않아 screenIdMap에 매핑 정보가 없었음 - 해결: 2단계 복사 방식 도입 1단계: 모든 screen_definitions 먼저 복사하여 screenIdMap 완성 2단계: screen_layouts 복사하면서 완성된 screenIdMap으로 참조 업데이트 - 결과: targetScreenId가 올바르게 새 회사의 화면 ID로 매핑됨 (예: 149 → 517) - 추가: 화면 수집 시 문자열 타입 ID도 올바르게 파싱하도록 개선 - 추가: 참조 화면 발견 및 업데이트 로그 추가 관련 파일: - backend-node/src/services/menuCopyService.ts - db/migrations/1003_add_source_menu_objid_to_menu_info.sql - db/scripts/cleanup_company_11_*.sql
This commit is contained in:
parent
bb49073bf7
commit
c70998fa4f
|
|
@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService";
|
|||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||
import { MenuCopyService } from "../services/menuCopyService";
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
|
|
@ -3253,3 +3254,84 @@ export async function getTableSchema(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 복사
|
||||
* POST /api/admin/menus/:menuObjid/copy
|
||||
*/
|
||||
export async function copyMenu(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { menuObjid } = req.params;
|
||||
const { targetCompanyCode } = req.body;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const userType = req.user!.userType;
|
||||
const isSuperAdmin = req.user!.isSuperAdmin;
|
||||
|
||||
logger.info(`
|
||||
=== 메뉴 복사 API 호출 ===
|
||||
menuObjid: ${menuObjid}
|
||||
targetCompanyCode: ${targetCompanyCode}
|
||||
userId: ${userId}
|
||||
userCompanyCode: ${userCompanyCode}
|
||||
userType: ${userType}
|
||||
isSuperAdmin: ${isSuperAdmin}
|
||||
`);
|
||||
|
||||
// 권한 체크: 최고 관리자만 가능
|
||||
if (!isSuperAdmin && userType !== "SUPER_ADMIN") {
|
||||
logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다",
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
details: "Only super admin can copy menus",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!menuObjid || !targetCompanyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다",
|
||||
error: {
|
||||
code: "MISSING_PARAMETERS",
|
||||
details: "menuObjid and targetCompanyCode are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 메뉴 복사 실행
|
||||
const menuCopyService = new MenuCopyService();
|
||||
const result = await menuCopyService.copyMenu(
|
||||
parseInt(menuObjid, 10),
|
||||
targetCompanyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
logger.info("✅ 메뉴 복사 API 성공");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "메뉴 복사 완료",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 메뉴 복사 API 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "메뉴 복사 중 오류가 발생했습니다",
|
||||
error: {
|
||||
code: "MENU_COPY_ERROR",
|
||||
details: error.message || "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
deleteMenu, // 메뉴 삭제
|
||||
deleteMenusBatch, // 메뉴 일괄 삭제
|
||||
toggleMenuStatus, // 메뉴 상태 토글
|
||||
copyMenu, // 메뉴 복사
|
||||
getUserList,
|
||||
getUserInfo, // 사용자 상세 조회
|
||||
getUserHistory, // 사용자 변경이력 조회
|
||||
|
|
@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus);
|
|||
router.get("/user-menus", getUserMenus);
|
||||
router.get("/menus/:menuId", getMenuInfo);
|
||||
router.post("/menus", saveMenu); // 메뉴 추가
|
||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
|
||||
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
|
||||
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,184 @@
|
|||
# 마이그레이션 1003: source_menu_objid 추가
|
||||
|
||||
## 📋 개요
|
||||
|
||||
메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다.
|
||||
|
||||
## 🎯 목적
|
||||
|
||||
### 이전 방식의 문제점
|
||||
- 메뉴 이름으로만 기존 복사본 판단
|
||||
- 같은 이름의 다른 메뉴도 삭제될 위험
|
||||
- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가
|
||||
|
||||
### 개선 후
|
||||
- 원본 메뉴 ID로 정확히 추적
|
||||
- 같은 원본에서 복사된 메뉴만 덮어쓰기
|
||||
- 수동 메뉴와 복사 메뉴 명확히 구분
|
||||
|
||||
## 🗄️ 스키마 변경
|
||||
|
||||
### 추가되는 컬럼
|
||||
```sql
|
||||
ALTER TABLE menu_info
|
||||
ADD COLUMN source_menu_objid BIGINT;
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
```sql
|
||||
-- 단일 인덱스
|
||||
CREATE INDEX idx_menu_info_source_menu_objid
|
||||
ON menu_info(source_menu_objid);
|
||||
|
||||
-- 복합 인덱스 (회사별 검색 최적화)
|
||||
CREATE INDEX idx_menu_info_source_company
|
||||
ON menu_info(source_menu_objid, company_code);
|
||||
```
|
||||
|
||||
## 📊 데이터 구조
|
||||
|
||||
### 복사된 메뉴의 source_menu_objid 값
|
||||
|
||||
| 메뉴 레벨 | source_menu_objid | 설명 |
|
||||
|-----------|-------------------|------|
|
||||
| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 |
|
||||
| 하위 메뉴 | NULL | 최상위 메뉴만 추적 |
|
||||
| 수동 생성 메뉴 | NULL | 복사가 아님 |
|
||||
|
||||
### 예시
|
||||
|
||||
#### 원본 (COMPANY_7)
|
||||
```
|
||||
- 사용자 (objid: 1762407678882)
|
||||
└─ 영업관리 (objid: 1762421877772)
|
||||
└─ 거래처관리 (objid: 1762421920304)
|
||||
```
|
||||
|
||||
#### 복사본 (COMPANY_11)
|
||||
```
|
||||
- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적
|
||||
└─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL)
|
||||
└─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL)
|
||||
```
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 로컬 PostgreSQL
|
||||
```bash
|
||||
psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||
```
|
||||
|
||||
### Docker 환경
|
||||
```bash
|
||||
# 백엔드 컨테이너를 통해 실행
|
||||
docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||
```
|
||||
|
||||
### DBeaver / pgAdmin
|
||||
1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기
|
||||
2. 전체 스크립트 실행
|
||||
|
||||
## ✅ 확인 방법
|
||||
|
||||
### 1. 컬럼 추가 확인
|
||||
```sql
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'source_menu_objid';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
column_name | data_type | is_nullable
|
||||
-------------------|-----------|-------------
|
||||
source_menu_objid | bigint | YES
|
||||
```
|
||||
|
||||
### 2. 인덱스 생성 확인
|
||||
```sql
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'menu_info'
|
||||
AND indexname LIKE '%source%';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
indexname | indexdef
|
||||
---------------------------------|----------------------------------
|
||||
idx_menu_info_source_menu_objid | CREATE INDEX ...
|
||||
idx_menu_info_source_company | CREATE INDEX ...
|
||||
```
|
||||
|
||||
### 3. 기존 데이터 확인
|
||||
```sql
|
||||
-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터)
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(source_menu_objid) as with_source
|
||||
FROM menu_info;
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
total | with_source
|
||||
------|-------------
|
||||
114 | 0
|
||||
```
|
||||
|
||||
## 🔄 롤백 (필요 시)
|
||||
|
||||
```sql
|
||||
-- 인덱스 삭제
|
||||
DROP INDEX IF EXISTS idx_menu_info_source_menu_objid;
|
||||
DROP INDEX IF EXISTS idx_menu_info_source_company;
|
||||
|
||||
-- 컬럼 삭제
|
||||
ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid;
|
||||
```
|
||||
|
||||
## 📝 주의사항
|
||||
|
||||
1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다.
|
||||
2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다.
|
||||
3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다.
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
### 1. 첫 복사 (source_menu_objid 설정)
|
||||
```
|
||||
원본: 사용자 (objid: 1762407678882, COMPANY_7)
|
||||
복사: 사용자 (objid: 1763688215729, COMPANY_11)
|
||||
source_menu_objid: 1762407678882 ✅
|
||||
```
|
||||
|
||||
### 2. 재복사 (정확한 덮어쓰기)
|
||||
```
|
||||
복사 전 조회:
|
||||
SELECT objid FROM menu_info
|
||||
WHERE source_menu_objid = 1762407678882
|
||||
AND company_code = 'COMPANY_11'
|
||||
→ 1763688215729 발견
|
||||
|
||||
동작:
|
||||
1. objid=1763688215729의 메뉴 트리 전체 삭제
|
||||
2. 새로 복사 (source_menu_objid: 1762407678882)
|
||||
```
|
||||
|
||||
### 3. 다른 메뉴는 영향 없음
|
||||
```
|
||||
수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11)
|
||||
source_menu_objid: NULL ✅
|
||||
|
||||
"사용자" 메뉴 재복사 시:
|
||||
→ 관리자 메뉴는 그대로 유지 ✅
|
||||
```
|
||||
|
||||
## 📚 관련 파일
|
||||
|
||||
- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql`
|
||||
- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts`
|
||||
- `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기
|
||||
- `copyMenus()`: 복사 시 source_menu_objid 저장
|
||||
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# 마이그레이션 1003 실행 가이드
|
||||
|
||||
## ❌ 현재 에러
|
||||
```
|
||||
column "source_menu_objid" does not exist
|
||||
```
|
||||
|
||||
**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음
|
||||
|
||||
## ✅ 해결 방법
|
||||
|
||||
### 방법 1: psql 직접 실행 (권장)
|
||||
|
||||
```bash
|
||||
# 1. PostgreSQL 접속 정보 확인
|
||||
# - Host: localhost (또는 실제 DB 호스트)
|
||||
# - Port: 5432 (기본값)
|
||||
# - Database: ilshin
|
||||
# - User: postgres
|
||||
|
||||
# 2. 마이그레이션 실행
|
||||
cd /Users/kimjuseok/ERP-node
|
||||
psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||
|
||||
# 또는 대화형으로
|
||||
psql -h localhost -U postgres -d ilshin
|
||||
# 그 다음 파일 내용 붙여넣기
|
||||
```
|
||||
|
||||
### 방법 2: DBeaver / pgAdmin에서 실행
|
||||
|
||||
1. DBeaver 또는 pgAdmin 실행
|
||||
2. `ilshin` 데이터베이스 연결
|
||||
3. SQL 편집기 열기
|
||||
4. 아래 SQL 복사하여 실행:
|
||||
|
||||
```sql
|
||||
-- source_menu_objid 컬럼 추가
|
||||
ALTER TABLE menu_info
|
||||
ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
|
||||
|
||||
-- 인덱스 생성 (검색 성능 향상)
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid
|
||||
ON menu_info(source_menu_objid);
|
||||
|
||||
-- 복합 인덱스: 회사별 원본 메뉴 검색
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company
|
||||
ON menu_info(source_menu_objid, company_code);
|
||||
|
||||
-- 컬럼 설명 추가
|
||||
COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)';
|
||||
|
||||
-- 확인
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'source_menu_objid';
|
||||
```
|
||||
|
||||
### 방법 3: Docker를 통한 실행
|
||||
|
||||
Docker Compose 설정 확인 후:
|
||||
|
||||
```bash
|
||||
# Docker Compose에 DB 서비스가 있는 경우
|
||||
docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql
|
||||
|
||||
# 또는 SQL을 직접 실행
|
||||
docker-compose exec db psql -U postgres -d ilshin -c "
|
||||
ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid);
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code);
|
||||
"
|
||||
```
|
||||
|
||||
## ✅ 실행 후 확인
|
||||
|
||||
### 1. 컬럼이 추가되었는지 확인
|
||||
```sql
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'source_menu_objid';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
column_name | data_type | is_nullable
|
||||
-------------------|-----------|-------------
|
||||
source_menu_objid | bigint | YES
|
||||
```
|
||||
|
||||
### 2. 인덱스 확인
|
||||
```sql
|
||||
SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'menu_info'
|
||||
AND indexname LIKE '%source%';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
indexname
|
||||
---------------------------------
|
||||
idx_menu_info_source_menu_objid
|
||||
idx_menu_info_source_company
|
||||
```
|
||||
|
||||
### 3. 메뉴 복사 재시도
|
||||
마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요.
|
||||
|
||||
## 🔍 DB 접속 정보 찾기
|
||||
|
||||
### 환경 변수 확인
|
||||
```bash
|
||||
# .env 파일 확인
|
||||
cat backend-node/.env | grep DB
|
||||
|
||||
# Docker Compose 확인
|
||||
cat docker-compose*.yml | grep -A 10 postgres
|
||||
```
|
||||
|
||||
### 일반적인 접속 정보
|
||||
- **Host**: localhost 또는 127.0.0.1
|
||||
- **Port**: 5432 (기본값)
|
||||
- **Database**: ilshin
|
||||
- **User**: postgres
|
||||
- **Password**: (환경 설정 파일에서 확인)
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장
|
||||
2. **권한 확인**: ALTER TABLE 권한이 필요합니다
|
||||
3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다
|
||||
|
||||
## 📞 문제 해결
|
||||
|
||||
### "permission denied" 에러
|
||||
→ postgres 사용자 또는 superuser 권한으로 실행 필요
|
||||
|
||||
### "relation does not exist" 에러
|
||||
→ 올바른 데이터베이스(ilshin)에 접속했는지 확인
|
||||
|
||||
### "already exists" 에러
|
||||
→ 이미 실행됨. 무시하고 진행 가능
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# COMPANY_11 테스트 데이터 정리 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다.
|
||||
|
||||
## ⚠️ 중요 사항
|
||||
|
||||
- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`)
|
||||
- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드
|
||||
- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능
|
||||
- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 방법 1: Docker 컨테이너에서 직접 실행 (권장)
|
||||
|
||||
```bash
|
||||
# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨)
|
||||
cd /Users/kimjuseok/ERP-node
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql
|
||||
|
||||
# 2. 실제 삭제 실행
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||
```
|
||||
|
||||
### 방법 2: DBeaver 또는 pgAdmin에서 실행
|
||||
|
||||
1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기
|
||||
2. 전체 스크립트 실행 (롤백되어 안전)
|
||||
3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행
|
||||
|
||||
### 방법 3: psql 직접 접속
|
||||
|
||||
```bash
|
||||
# 1. 컨테이너 접속
|
||||
docker exec -it erp-node-db-1 psql -U postgres -d ilshin
|
||||
|
||||
# 2. SQL 복사 붙여넣기
|
||||
# (cleanup_company_11_execute.sql 내용 복사)
|
||||
```
|
||||
|
||||
## 📊 삭제 대상
|
||||
|
||||
| 항목 | 테이블명 | 삭제 여부 |
|
||||
|------|----------|-----------|
|
||||
| 메뉴 | `menu_info` | ✅ 삭제 |
|
||||
| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 |
|
||||
| 화면 정의 | `screen_definitions` | ✅ 삭제 |
|
||||
| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 |
|
||||
| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 |
|
||||
| 플로우 정의 | `flow_definition` | ✅ 삭제 |
|
||||
| 플로우 스텝 | `flow_step` | ✅ 삭제 |
|
||||
| 플로우 연결 | `flow_step_connection` | ✅ 삭제 |
|
||||
| 코드 카테고리 | `code_category` | ✅ 삭제 |
|
||||
| 코드 정보 | `code_info` | ✅ 삭제 |
|
||||
| **권한 그룹** | `authority_master` | ❌ **보존** |
|
||||
| **권한 멤버** | `authority_sub_user` | ❌ **보존** |
|
||||
| **사용자** | `user_info` | ❌ **보존** |
|
||||
|
||||
## 🔍 삭제 순서 (외래키 제약 고려)
|
||||
|
||||
```
|
||||
1. screen_layouts (화면 레이아웃)
|
||||
2. screen_menu_assignments (화면-메뉴 할당)
|
||||
3. screen_definitions (화면 정의)
|
||||
4. rel_menu_auth (메뉴 권한)
|
||||
5. menu_info (메뉴)
|
||||
6. flow_step (플로우 스텝)
|
||||
7. flow_step_connection (플로우 연결)
|
||||
8. flow_definition (플로우 정의)
|
||||
9. code_info (코드 정보)
|
||||
10. code_category (코드 카테고리)
|
||||
```
|
||||
|
||||
## ✅ 실행 후 확인
|
||||
|
||||
스크립트 실행 후 다음과 같이 표시됩니다:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
✅ 삭제 완료!
|
||||
|
||||
남은 데이터:
|
||||
- 메뉴: 0 개
|
||||
- 화면: 0 개
|
||||
- 권한 그룹: 1 개 (보존됨)
|
||||
- 사용자: 1 개 (보존됨)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
✨ 정리 완료! 메뉴 복사 테스트 준비됨
|
||||
```
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
1. **데이터 정리**
|
||||
```bash
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||
```
|
||||
|
||||
2. **메뉴 복사 실행**
|
||||
- 프론트엔드에서 원본 메뉴 선택
|
||||
- "복사" 버튼 클릭
|
||||
- 대상 회사: COMPANY_11 선택
|
||||
- 복사 실행
|
||||
|
||||
3. **복사 결과 확인**
|
||||
- COMPANY_11 사용자(copy)로 로그인
|
||||
- 사용자 메뉴에 복사된 메뉴 표시 확인
|
||||
- 버튼 클릭 시 모달 화면 정상 열림 확인
|
||||
- 플로우 기능 정상 작동 확인
|
||||
|
||||
## 🔄 재테스트
|
||||
|
||||
재테스트가 필요하면 다시 정리 스크립트를 실행하세요:
|
||||
|
||||
```bash
|
||||
# 빠른 재테스트
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||
```
|
||||
|
||||
## 📝 참고
|
||||
|
||||
- **백업**: 중요한 데이터가 있다면 먼저 백업하세요
|
||||
- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다
|
||||
- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { menuApi, MenuCopyResult } from "@/lib/api/menu";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface MenuCopyDialogProps {
|
||||
menuObjid: number | null;
|
||||
menuName: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCopyComplete?: () => void;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
}
|
||||
|
||||
export function MenuCopyDialog({
|
||||
menuObjid,
|
||||
menuName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCopyComplete,
|
||||
}: MenuCopyDialogProps) {
|
||||
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [result, setResult] = useState<MenuCopyResult | null>(null);
|
||||
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadCompanies();
|
||||
// 다이얼로그가 열릴 때마다 초기화
|
||||
setTargetCompanyCode("");
|
||||
setResult(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
setLoadingCompanies(true);
|
||||
const response = await apiClient.get("/admin/companies/db");
|
||||
if (response.data.success && response.data.data) {
|
||||
// 최고 관리자(*) 회사 제외
|
||||
const filteredCompanies = response.data.data.filter(
|
||||
(company: Company) => company.company_code !== "*"
|
||||
);
|
||||
setCompanies(filteredCompanies);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("회사 목록 조회 실패:", error);
|
||||
toast.error("회사 목록을 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoadingCompanies(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!menuObjid) {
|
||||
toast.error("메뉴를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetCompanyCode) {
|
||||
toast.error("대상 회사를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setCopying(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await menuApi.copyMenu(menuObjid, targetCompanyCode);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data);
|
||||
toast.success("메뉴 복사 완료!");
|
||||
|
||||
// 경고 메시지 표시
|
||||
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||
response.data.warnings.forEach((warning) => {
|
||||
toast.warning(warning);
|
||||
});
|
||||
}
|
||||
|
||||
// 복사 완료 콜백
|
||||
if (onCopyComplete) {
|
||||
onCopyComplete();
|
||||
}
|
||||
} else {
|
||||
toast.error(response.message || "메뉴 복사 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("메뉴 복사 오류:", error);
|
||||
toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!copying) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<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">
|
||||
"{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 회사 선택 */}
|
||||
{!result && (
|
||||
<div>
|
||||
<Label htmlFor="company" className="text-xs sm:text-sm">
|
||||
대상 회사 *
|
||||
</Label>
|
||||
<Select
|
||||
value={targetCompanyCode}
|
||||
onValueChange={setTargetCompanyCode}
|
||||
disabled={copying || loadingCompanies}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="company"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingCompanies ? (
|
||||
<div className="flex items-center justify-center p-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : companies.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
사용 가능한 회사가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
companies.map((company) => (
|
||||
<SelectItem
|
||||
key={company.company_code}
|
||||
value={company.company_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{company.company_name} ({company.company_code})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 복사 항목 안내 */}
|
||||
{!result && (
|
||||
<div className="rounded-md border p-3 text-xs">
|
||||
<p className="font-medium mb-2">복사되는 항목:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||
<li>플로우 제어 (스텝, 연결)</li>
|
||||
<li>코드 카테고리 + 코드</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-warning">
|
||||
⚠️ 실제 데이터는 복사되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 복사 결과 */}
|
||||
{result && (
|
||||
<div className="rounded-md border border-success bg-success/10 p-3 text-xs space-y-2">
|
||||
<p className="font-medium text-success">✅ 복사 완료!</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">메뉴:</span>{" "}
|
||||
<span className="font-medium">{result.copiedMenus}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">화면:</span>{" "}
|
||||
<span className="font-medium">{result.copiedScreens}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||
<span className="font-medium">{result.copiedFlows}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCategories}개</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">코드:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCodes}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={copying}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{result ? "닫기" : "취소"}
|
||||
</Button>
|
||||
{!result && (
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
disabled={copying || !targetCompanyCode || loadingCompanies}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{copying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
"복사 시작"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu";
|
|||
import type { MenuItem } from "@/lib/api/menu";
|
||||
import { MenuTable } from "./MenuTable";
|
||||
import { MenuFormModal } from "./MenuFormModal";
|
||||
import { MenuCopyDialog } from "./MenuCopyDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext";
|
|||
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
|
||||
|
||||
type MenuType = "admin" | "user";
|
||||
|
||||
export const MenuManagement: React.FC = () => {
|
||||
const { adminMenus, userMenus, refreshMenus } = useMenu();
|
||||
const { user } = useAuth(); // 현재 사용자 정보 가져오기
|
||||
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [formModalOpen, setFormModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
||||
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
|
||||
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
||||
|
||||
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
|
||||
|
|
@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => {
|
|||
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
|
||||
const { userLang } = useMultiLang({ companyCode: "*" });
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
|
||||
|
||||
// 다국어 텍스트 상태
|
||||
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||
const [uiTextsLoading, setUiTextsLoading] = useState(false);
|
||||
|
|
@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCopyMenu = (menuId: string, menuName: string) => {
|
||||
setSelectedMenuId(menuId);
|
||||
setSelectedMenuName(menuName);
|
||||
setCopyDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyComplete = async () => {
|
||||
// 복사 완료 후 메뉴 목록 새로고침
|
||||
await loadMenus(false);
|
||||
toast.success("메뉴 복사가 완료되었습니다");
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (menuId: string) => {
|
||||
try {
|
||||
const response = await menuApi.toggleMenuStatus(menuId);
|
||||
|
|
@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => {
|
|||
title=""
|
||||
onAddMenu={handleAddMenu}
|
||||
onEditMenu={handleEditMenu}
|
||||
onCopyMenu={handleCopyMenu}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
selectedMenus={selectedMenus}
|
||||
onMenuSelectionChange={handleMenuSelectionChange}
|
||||
|
|
@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => {
|
|||
expandedMenus={expandedMenus}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
uiTexts={uiTexts}
|
||||
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<MenuCopyDialog
|
||||
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
|
||||
menuName={selectedMenuName}
|
||||
open={copyDialogOpen}
|
||||
onOpenChange={setCopyDialogOpen}
|
||||
onCopyComplete={handleCopyComplete}
|
||||
/>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface MenuTableProps {
|
|||
title: string;
|
||||
onAddMenu: (parentId: string, menuType: string, level: number) => void;
|
||||
onEditMenu: (menuId: string) => void;
|
||||
onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가
|
||||
onToggleStatus: (menuId: string) => void;
|
||||
selectedMenus: Set<string>;
|
||||
onMenuSelectionChange: (menuId: string, checked: boolean) => void;
|
||||
|
|
@ -22,6 +23,7 @@ interface MenuTableProps {
|
|||
onToggleExpand: (menuId: string) => void;
|
||||
// 다국어 텍스트 props 추가
|
||||
uiTexts: Record<string, string>;
|
||||
isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가
|
||||
}
|
||||
|
||||
export const MenuTable: React.FC<MenuTableProps> = ({
|
||||
|
|
@ -29,6 +31,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
title,
|
||||
onAddMenu,
|
||||
onEditMenu,
|
||||
onCopyMenu,
|
||||
onToggleStatus,
|
||||
selectedMenus,
|
||||
onMenuSelectionChange,
|
||||
|
|
@ -36,6 +39,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
expandedMenus,
|
||||
onToggleExpand,
|
||||
uiTexts,
|
||||
isSuperAdmin = false, // 기본값 false
|
||||
}) => {
|
||||
// 다국어 텍스트 가져오기 함수
|
||||
const getText = (key: string, fallback?: string): string => {
|
||||
|
|
@ -281,14 +285,26 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
<TableCell className="h-16">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{lev === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lev === 2 && (
|
||||
<>
|
||||
|
|
@ -308,17 +324,39 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lev > 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onEditMenu(objid)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onEditMenu(objid)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -162,4 +162,40 @@ export const menuApi = {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 메뉴 복사
|
||||
copyMenu: async (
|
||||
menuObjid: number,
|
||||
targetCompanyCode: string
|
||||
): Promise<ApiResponse<MenuCopyResult>> => {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/admin/menus/${menuObjid}/copy`,
|
||||
{ targetCompanyCode }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 메뉴 복사 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다",
|
||||
errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 메뉴 복사 결과
|
||||
*/
|
||||
export interface MenuCopyResult {
|
||||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue