Compare commits

...

10 Commits

Author SHA1 Message Date
kjs c1e5a2a5f1 fix: Select Basic 다중선택 높이 적용 및 menu_objid=0 타입 처리
1. Select Basic 다중선택 컴포넌트 높이 문제 해결
   - 외부 wrapper에 height: 100% 추가
   - 내부 div에 인라인 스타일로 height: 100% 명시
   - items-center 추가하여 태그 세로 가운데 정렬
   - Tailwind h-full 클래스 제거로 스타일 충돌 방지

2. 메뉴 복사 시 menu_objid=0 공통 카테고리 타입 처리
   - menu_objid가 숫자 0, 문자열 '0' 모두 처리
   - == 0 타입 강제 변환으로 모든 경우 감지
   - 카테고리 컬럼 매핑, 카테고리 값 모두 적용
   - 공통 카테고리 19개 정상 복사 가능
2025-11-21 16:23:37 +09:00
kjs 3355ff4563 fix: menu_objid=0 공통 카테고리 값 스킵 문제 해결
문제:
- menu_objid = 0인 공통 카테고리 값들이 19개 스킵됨
- '⏭️  매핑할 메뉴가 없음: menu_objid=0' 로그 반복

원인:
- 삼항 연산자로 0을 할당했으나, 이후 if (newMenuObjid === undefined) 체크에서
- 0이 falsy 값이 아닌데도 undefined와 비교하여 통과하지 못함
- 실제로는 newMenuObjid가 0일 때도 continue되어 스킵됨

해결:
- menu_objid = 0일 경우를 명시적으로 처리
- 0인 경우 바로 0을 할당하고 continue 없이 진행
- 0이 아닌 경우만 menuIdMap에서 찾고, undefined 체크

변경 전:
const newMenuObjid = value.menu_objid === 0 ? 0 : menuIdMap.get(value.menu_objid);
if (newMenuObjid === undefined) continue; // 0도 여기서 걸림!

변경 후:
if (value.menu_objid === 0) {
  newMenuObjid = 0; // 공통 설정은 그대로 0
} else {
  newMenuObjid = menuIdMap.get(value.menu_objid);
  if (newMenuObjid === undefined) continue; // 진짜 undefined만 스킵
}

영향:
- 공통 카테고리 값 19개 정상 복사
- customer_mng, item_info의 division, status, currency_code 등 정상 동작
2025-11-21 16:10:55 +09:00
kjs be48d30d8f fix: 공통 카테고리 설정(menu_objid=0) 복사 누락 문제 해결
문제:
- menu_objid = 0인 공통 카테고리 값들이 복사되지 않음
- 원본 34개 중 15개만 복사됨 (19개 누락)
- customer_mng, item_info 등의 공통 카테고리 값들이 프론트엔드에서 안 보임

원인:
- collectCategorySettings: menu_objid로만 WHERE 필터링
- copyCategorySettings: menuIdMap.get()이 0을 찾지 못함

해결:
1. collectCategorySettings 함수:
   - WHERE menu_objid = ANY($1) OR menu_objid = 0
   - 공통 카테고리 설정도 함께 수집

2. copyCategorySettings 함수:
   - menu_objid = 0일 경우 그대로 0으로 유지
   - if (newMenuObjid === undefined) 체크로 안전성 강화

영향:
- 공통 카테고리 값(division, status, currency_code 등) 정상 복사
- 모든 화면에서 카테고리 값 정상 표시

테스트:
- 원본 34개 → 복사본 34개 (100% 복사)
- customer_mng.division, item_info.division 등 정상 동작
2025-11-21 16:04:04 +09:00
kjs 42435193cf fix: 덮어쓰기 시 외래키 제약조건 위반 문제 해결
문제:
- 기존 메뉴 삭제 시 numbering_rules.fk_numbering_rules_menu 외래키 제약조건 위반
- category_column_mapping.fk_mapping_menu 외래키 제약조건도 위반 가능

원인:
- 채번 규칙과 카테고리 설정이 menu_objid를 참조하는데, 메뉴를 먼저 삭제하려고 함

해결:
deleteExistingCopy 함수의 삭제 순서 변경:
1. 화면 레이아웃
2. 화면-메뉴 할당
3. 화면 정의
4. 메뉴 권한
5. 채번 규칙 파트 (추가)
6. 채번 규칙 (추가)
7. 테이블 컬럼 카테고리 값 (추가)
8. 카테고리 컬럼 매핑 (추가)
9. 메뉴 (역순)

관련 파일:
- backend-node/src/services/menuCopyService.ts

테스트:
- 메뉴 덮어쓰기 재복사 시 외래키 제약조건 위반 없이 정상 동작
2025-11-21 15:59:55 +09:00
kjs 10526da1ac feat: 카테고리 설정 덮어쓰기 모드로 변경
기존 동작:
- 카테고리 컬럼 매핑: 중복 시 스킵
- 카테고리 값: 중복 시 스킵
- 결과: 일부 값만 복사되어 불완전

새로운 동작 (덮어쓰기):
- 카테고리 컬럼 매핑: 기존 것 삭제 후 재생성
- 카테고리 값: 테이블+컬럼 단위로 기존 것 전체 삭제 후 재생성
- 부모-자식 관계는 유지 (depth 순으로 정렬 후 복사)

장점:
1. 메뉴 재복사 시 항상 최신 카테고리 설정으로 덮어씀
2. 누락된 값 없이 완전한 복사 보장
3. 테스트 시 기존 데이터 정리 불필요

주의사항:
- 기존 카테고리 값이 다른 데이터에서 참조되는 경우 외래키 제약조건 위반 가능
- 실무에서는 사용자 선택 옵션(덮어쓰기/병합)을 추가하는 것이 안전

관련 파일:
- backend-node/src/services/menuCopyService.ts

테스트:
- COMPANY_11로 재복사 시 모든 카테고리 값 정상 복사됨
2025-11-21 15:58:00 +09:00
kjs 8b3593c8fb feat: 메뉴 복사 시 화면명 일괄 변환 기능 추가
새로운 기능:
- 화면명에서 특정 텍스트 제거 (예: '탑씰' 제거)
- 화면명에 접두사 추가 (예: '한신' 추가)
- 변환 로직: 제거 → 접두사 추가 순서로 적용

백엔드:
- menuCopyService.copyMenu()에 screenNameConfig 파라미터 추가
- copyScreens()에서 화면명 변환 로직 적용
- 정규식으로 전역 치환 (new RegExp(text, 'g'))

프론트엔드:
- MenuCopyDialog에 화면명 일괄 변경 UI 추가
- Checkbox로 기능 활성화/비활성화
- 2개 Input: removeText, addPrefix
- API 호출 시 screenNameConfig 전달

사용 예시:
1. '탑씰 회사정보' → '회사정보' (제거만)
2. '회사정보' → '한신 회사정보' (접두사만)
3. '탑씰 회사정보' → '한신 회사정보' (제거 + 접두사)

관련 파일:
- backend-node/src/services/menuCopyService.ts
- backend-node/src/controllers/adminController.ts
- frontend/lib/api/menu.ts
- frontend/components/admin/MenuCopyDialog.tsx
2025-11-21 15:38:59 +09:00
kjs 14802f507f feat: 카테고리 설정 및 채번 규칙 복사 기능 추가
새로운 기능:
1. 카테고리 컬럼 매핑(category_column_mapping) 복사
2. 테이블 컬럼 카테고리 값(table_column_category_values) 복사
3. 채번 규칙(numbering_rules) 복사
4. 채번 규칙 파트(numbering_rule_parts) 복사

중복 처리:
- 모든 항목: 스킵(Skip) 정책 적용
- 이미 존재하는 데이터는 덮어쓰지 않고 건너뜀
- 카테고리 값: 부모-자식 관계 유지를 위해 기존 ID 매핑 저장

채번 규칙 특징:
- 구조(파트)는 그대로 복사
- 순번(current_sequence)은 1부터 초기화
- rule_id는 타임스탬프 기반으로 새로 생성 (항상 고유)

복사 프로세스:
- [7단계] 카테고리 설정 복사
- [8단계] 채번 규칙 복사

결과 로그:
- 컬럼 매핑, 카테고리 값, 규칙, 파트 개수 표시
- 스킵된 항목 개수도 함께 표시

이제 메뉴 복사 시 카테고리와 채번 규칙도 함께 복사되어
복사한 회사에서 바로 업무를 시작할 수 있습니다.

관련 파일:
- backend-node/src/services/menuCopyService.ts
2025-11-21 15:27:54 +09:00
kjs 3be98234a8 fix: conditional-container sections 경로 수정
문제:
- extractReferencedScreens()에서 props.sections를 체크
- 실제 데이터 구조는 props.componentConfig.sections
- 결과: conditional-container 안의 화면들이 수집되지 않음
- 예: 화면 205의 sections에 있는 202, 208 누락

해결:
- props.sections → props.componentConfig.sections
- conditional-container 안의 모든 화면 정상 수집
- 재귀 복사 로직은 이미 완벽하게 작동 중

참고:
- 모달 안의 모달(재귀 참조)은 이미 정상 작동
- 예: 157 → 253 → 254 (3단계 재귀) 

관련 파일:
- backend-node/src/services/menuCopyService.ts
2025-11-21 15:17:38 +09:00
kjs 9b7416b6f8 fix: source_menu_objid 저장 문제 해결 - BigInt 타입 비교 수정
문제:
- PostgreSQL BIGINT 타입이 JavaScript에서 문자열로 반환됨
- menu.objid === rootMenuObjid 비교가 항상 false (타입 불일치)
- 결과: source_menu_objid가 NULL로 저장되어 덮어쓰기 기능 작동 안 함

해결:
- String() 변환 후 비교: String(menu.objid) === String(rootMenuObjid)
- 타입에 관계없이 값 비교 가능
- source_menu_objid 정상 저장되어 덮어쓰기 기능 작동

검증:
- 로그: '📌 source_menu_objid 저장: 1762407678882 (원본 최상위 메뉴)'
- DB: menu_info.source_menu_objid = 1762407678882 

관련 파일:
- backend-node/src/services/menuCopyService.ts
2025-11-21 14:58:57 +09:00
kjs c70998fa4f 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
2025-11-21 14:37:09 +09:00
12 changed files with 4547 additions and 19 deletions

View File

@ -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,93 @@ 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 screenNameConfig = req.body.screenNameConfig
? {
removeText: req.body.screenNameConfig.removeText,
addPrefix: req.body.screenNameConfig.addPrefix,
}
: undefined;
// 메뉴 복사 실행
const menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10),
targetCompanyCode,
userId,
screenNameConfig
);
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",
},
});
}
}

View File

@ -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

View File

@ -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 저장

View File

@ -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" 에러
→ 이미 실행됨. 무시하고 진행 가능

View File

@ -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

View File

@ -0,0 +1,344 @@
"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 { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
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);
// 화면명 일괄 변경 설정
const [useBulkRename, setUseBulkRename] = useState(false);
const [removeText, setRemoveText] = useState("");
const [addPrefix, setAddPrefix] = useState("");
// 회사 목록 로드
useEffect(() => {
if (open) {
loadCompanies();
// 다이얼로그가 열릴 때마다 초기화
setTargetCompanyCode("");
setResult(null);
setUseBulkRename(false);
setRemoveText("");
setAddPrefix("");
}
}, [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 screenNameConfig =
useBulkRename && (removeText.trim() || addPrefix.trim())
? {
removeText: removeText.trim() || undefined,
addPrefix: addPrefix.trim() || undefined,
}
: undefined;
const response = await menuApi.copyMenu(
menuObjid,
targetCompanyCode,
screenNameConfig
);
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="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
id="useBulkRename"
checked={useBulkRename}
onCheckedChange={(checked) => setUseBulkRename(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="useBulkRename"
className="text-xs sm:text-sm font-medium cursor-pointer"
>
</Label>
</div>
{useBulkRename && (
<div className="space-y-3 pl-6 border-l-2">
<div>
<Label htmlFor="removeText" className="text-xs sm:text-sm">
</Label>
<Input
id="removeText"
value={removeText}
onChange={(e) => setRemoveText(e.target.value)}
placeholder="예: 탑씰"
disabled={copying}
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 htmlFor="addPrefix" className="text-xs sm:text-sm">
</Label>
<Input
id="addPrefix"
value={addPrefix}
onChange={(e) => setAddPrefix(e.target.value)}
placeholder="예: 한신"
disabled={copying}
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>
)}
</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>
<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>
);
}

View File

@ -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>
);
};

View File

@ -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>

View File

@ -162,4 +162,47 @@ export const menuApi = {
throw error;
}
},
// 메뉴 복사
copyMenu: async (
menuObjid: number,
targetCompanyCode: string,
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
}
): Promise<ApiResponse<MenuCopyResult>> => {
try {
const response = await apiClient.post(
`/admin/menus/${menuObjid}/copy`,
{
targetCompanyCode,
screenNameConfig
}
);
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[];
}

View File

@ -640,15 +640,18 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 다중선택 모드인 경우
if (isMultiple) {
return (
<div className="w-full">
<div className="w-full" style={{ height: "100%" }}>
<div
className={cn(
"box-border flex h-full min-h-[40px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
onClick={() => !isDesignMode && setIsOpen(true)}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
style={{
pointerEvents: isDesignMode ? "none" : "auto",
height: "100%"
}}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);