feat(WEB): 리스트 페이지 권한 시스템 통합 및 중복 권한 로직 제거

- PermissionContext 기능 확장 (권한 조회 액션 추가)
- usePermission 훅 개선
- 회계 모듈 권한 통합: 매입/매출/입금/지출/채권/거래처/어음/일보/부실채권
- 인사 모듈 권한 통합: 근태/카드/급여 관리
- 전자결재 권한 통합: 기안함/결재함
- 게시판/품목/단가/팝업/구독 리스트 권한 적용
- UniversalListPage 권한 연동
- 각 컴포넌트 중복 권한 체크 코드 제거 (-828줄)
- 권한 검증 QA 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-03 16:46:48 +09:00
parent e111f7b362
commit 17c16028b1
31 changed files with 1016 additions and 828 deletions

View File

@@ -0,0 +1,881 @@
# 권한 시스템 전체 검수 체크리스트
> **검수 기준**: 권한 설정 페이지에서 각 권한을 OFF로 전환 → 해당 UI 요소 숨김/차단 확인
> **검수 일자**: 2026-02-03
> **역할**: 개발자 (roleId: 28)
> **검수 방법**: 브라우저 레벨 검수 (코드 분석 아닌 실제 화면 조작)
### 테스트 방법론
| 권한 | 테스트 방법 | 검증 범위 |
|------|------------|----------|
| **조회(View)** | 전체 카테고리 조회=OFF → 60개 구현 페이지 전수 검사 | 전수 검사 완료 |
| **생성(Create)** | 품질관리 조회=OFF → "제품검사 등록" 버튼 숨김 확인 | 샘플 검증 (메커니즘 동일) |
| **수정(Update)** | 품질관리/인사관리 수정=OFF → "수정" 버튼 숨김 확인 | 샘플 검증 (메커니즘 동일) |
| **삭제(Delete)** | 인사관리 삭제=OFF → "삭제" 버튼 숨김 확인 | 샘플 검증 (메커니즘 동일) |
| **승인(Approve)** | 결재관리 승인=OFF → 결재함 "승인"/"반려" 버튼 확인 | 브라우저 직접 확인 → **미구현** |
| **내보내기(Export)** | 회계관리 내보내기=OFF → 거래처원장 "엑셀 다운로드" 확인 | 브라우저 직접 확인 → **구현 완료** ✅ |
| **관리(Manage)** | 코드 분석 → 타입 시스템 미등록 | 코드 분석 → **미구현** |
> **메커니즘 검증 원리**: 모든 페이지가 동일한 `usePermission` 훅 + `PermissionGate` 컴포넌트를 사용하므로, 대표 페이지 샘플 테스트로 전체 메커니즘 검증 가능.
> ULP(UniversalListPage) 템플릿은 canCreate/canDelete 자동 적용, IDT(IntegratedDetailTemplate)는 canUpdate/canDelete 자동 적용.
## 범례
- [x] = 정상 동작 확인 (직접 테스트 또는 메커니즘 검증)
- [ ] = 미검수
- N/A = 해당 기능 없음 (버튼/액션이 페이지에 존재하지 않음)
- **BUG** = 권한 미적용 (이슈 발견)
- 미구현 = 페이지 404 (개발 전)
- BYPASS = 의도적으로 권한 체크 제외
---
## 1. 품질관리
### 1-1. 제품검사관리 `/quality/inspections`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "제품검사 등록" 버튼 | **직접 테스트**: OFF 시 버튼 숨김 확인 |
| 수정 | [x] | 수정 버튼 (상세) | **직접 테스트**: 상세 페이지에서 "수정" 버튼 숨김 확인 |
| 삭제 | [x] | 삭제 버튼 | 목록에서 체크 시 "N개 항목 선택됨"만 표시 (목록 삭제 버튼 없음). 메커니즘 검증 |
### 1-2. 품질인정심사 `/quality/qms`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 체크리스트형 페이지, CRUD 버튼/체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
---
## 2. 품목관리
### 2-1. 품목기준관리 `/master-data/item-master-data-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | 탭별 항목 등록 | 메커니즘 검증. 탭형 설정 페이지 |
| 수정 | [x] | 행별 "수정" 버튼 | 메커니즘 검증. 체크박스 없음, 행별 수정/삭제 항상 표시 |
| 삭제 | [x] | 행별 "삭제" 버튼 | 메커니즘 검증 |
---
## 3. 결재관리
### 3-1. 기안함 `/approval/draft`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "문서 작성" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 | 메커니즘 검증 |
| 삭제 | [x] | "삭제" 버튼 | 메커니즘 검증. 체크→"상신"/"삭제" 노출 (툴바형) |
### 3-2. 결재함 `/approval/inbox`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 결재함에서 생성 없음 |
| 수정 | [x] | "승인"/"반려" 버튼 | 메커니즘 검증. 체크→"승인"/"반려" 노출 (툴바형) |
| 삭제 | N/A | | |
### 3-3. 참조함 `/approval/reference`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | |
| 수정 | N/A | | 체크→"열람"/"미열람" 노출 (툴바형) |
| 삭제 | N/A | | |
---
## 4. 게시판
### 4-1. 게시판 관리 `/board/board-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "게시판 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)" 노출 (툴바형) |
### 4-2. 자유게시판 `/boards/free`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "글쓰기" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 체크→삭제 ❌, 체크→작업 ❌ |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
### 4-3. 게시판 테스트 `/boards/board_mjsgri54_1fmg`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 자유게시판과 동일 구조 |
| 생성 | [x] | "글쓰기" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증 |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
---
## 5. 인사관리
### 5-1. 사원관리 `/hr/employee-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "사원 등록" 버튼 | 메커니즘 검증. 체크박스 없음 (hasCheckbox: false) |
| 수정 | [x] | 수정 버튼 | **직접 테스트**: 수정=OFF 시 체크박스 선택해도 "수정" 버튼 미노출 확인 |
| 삭제 | [x] | 삭제 버튼 | **직접 테스트**: 삭제=OFF 시 체크박스 선택해도 "삭제" 버튼 미노출 확인 |
### 5-2. 부서관리 `/hr/department-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "추가" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 행별 수정 버튼 | 메커니즘 검증. 작업 컬럼 항상 표시 |
| 삭제 | [x] | "삭제" 버튼 | 메커니즘 검증 |
### 5-3. 카드관리 `/hr/card-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "카드 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
### 5-4. 근태현황 `/hr/attendance`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | "출근하기" 버튼만 존재. 체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 5-5. 근태관리 `/hr/attendance-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | **BUG** | PermissionGate 페이지 차단 | **조회=OFF인데 페이지 접근 가능 (AccessDenied 미표시)** |
| 생성 | [x] | "사유 등록", "근태 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | N/A | | 삭제 버튼 없음 |
### 5-6. 급여관리 `/hr/salary-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 등록 버튼 없음 |
| 수정 | [x] | 행별 "수정" 버튼 (항상 표시) | 메커니즘 검증. 작업 컬럼 항상 표시 |
| 삭제 | N/A | | 체크→삭제 ❌ |
### 5-7. 휴가관리 `/hr/vacation-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 탭형 현황 조회 페이지 |
| 생성 | N/A | | CRUD 버튼 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
---
## 6. 리포트
### 6-1. CEO 대시보드 `/reports/comprehensive-analysis`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 대시보드형, CRUD 없음 |
| 생성 | N/A | | |
| 수정 | N/A | | |
| 삭제 | N/A | | |
---
## 7. 고객센터
### 7-1. 공지사항 `/customer-center/notices`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 리스트에 등록 버튼 없음 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→삭제 ✅ |
### 7-2. 1:1 문의 `/customer-center/qna`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "문의 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→삭제 ✅ |
### 7-3. FAQ `/customer-center/faq`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 카테고리별 아코디언 형식 |
| 생성 | N/A | | CRUD 버튼/체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 7-4. 이벤트 `/customer-center/events`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | CRUD 버튼/체크박스 없음. 진행중/종료 이벤트 탭만 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
---
## 8. 설정
### 8-1. 계정정보 `/settings/account-info`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 본인 계정 전용 |
| 수정 | [x] | "수정", "변경" 버튼 | 메커니즘 검증. 체크박스는 토글 스위치 |
| 삭제 | [x] | "탈퇴", "사용중지" 버튼 | 메커니즘 검증 |
### 8-2. 계좌관리 `/settings/accounts`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "계좌 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 행별 "수정" 버튼 | 메커니즘 검증. 작업 컬럼+수정/삭제 항상 표시 |
| 삭제 | [x] | 행별 "삭제" 버튼 | 메커니즘 검증 |
### 8-3. 권한관리 `/settings/permissions`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | BYPASS | | **BYPASS**: 자기 잠금 방지로 항상 접근 허용 (`BYPASS_PATHS` 설정) |
| 생성 | [x] | 역할 등록 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 | 메커니즘 검증 |
| 삭제 | [x] | 삭제 버튼 | 메커니즘 검증 |
### 8-4. 직급관리 `/settings/ranks`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "추가" 버튼 | 메커니즘 검증. 체크박스 없음 |
| 수정 | [x] | 행별 "수정" 버튼 | 메커니즘 검증. 행별 수정/삭제 항상 표시 |
| 삭제 | [x] | 행별 "삭제" 버튼 | 메커니즘 검증 |
### 8-5. 직책관리 `/settings/titles`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "추가" 버튼 | 메커니즘 검증. 체크박스 없음 |
| 수정 | [x] | 행별 "수정" 버튼 | 메커니즘 검증. 행별 수정/삭제 항상 표시 |
| 삭제 | [x] | 행별 "삭제" 버튼 | 메커니즘 검증 |
### 8-6. 출퇴근관리 `/settings/attendance-settings`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 설정 페이지 (지도+부서선택) |
| 생성 | N/A | | |
| 수정 | [x] | "저장" 버튼 | 메커니즘 검증 |
| 삭제 | N/A | | |
### 8-7. 휴가정책 `/settings/leave-policy`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 설정 페이지 |
| 생성 | N/A | | |
| 수정 | [x] | "저장" 버튼 | 메커니즘 검증 |
| 삭제 | N/A | | |
### 8-8. 근무관리 `/settings/work-schedule`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 설정 페이지 (근무시간/요일 체크박스) |
| 생성 | N/A | | |
| 수정 | [x] | "저장" 버튼 | 메커니즘 검증 |
| 삭제 | N/A | | |
### 8-9. 알림설정 `/settings/notification-settings`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 설정 페이지 (알림음 선택/토글) |
| 생성 | N/A | | |
| 수정 | [x] | "항목 설정", "저장" 버튼 | 메커니즘 검증 |
| 삭제 | N/A | | |
### 8-10. 팝업관리 `/settings/popup-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "팝업 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)" 노출 (툴바형) |
### 8-11. 회사정보 `/company-info`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "회사 추가" 버튼 | 메커니즘 검증. 체크박스 없음 |
| 수정 | [x] | "수정" 버튼 | 메커니즘 검증 |
| 삭제 | N/A | | |
### 8-12. 구독관리 `/subscription`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | |
| 수정 | N/A | | "자료 내보내기", "서비스 해지" 버튼만 |
| 삭제 | N/A | | |
---
## 9. 판매관리
### 9-1. 거래처관리 `/sales/client-management-sales-admin`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "거래처 등록", "신규업체" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
### 9-2. 견적관리 `/sales/quote-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "견적 등록" 버튼 | 메커니즘 검증. 데이터 없어 체크박스 헤더만 존재 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증 |
| 삭제 | [x] | 삭제 버튼 | 메커니즘 검증 |
### 9-3. 수주관리 `/sales/order-management-sales`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "수주 등록", "수주완료" 버튼 | 메커니즘 검증 |
| 수정 | [x] | "수정" 버튼 | 메커니즘 검증. 체크→상세/수정/삭제 노출 (툴바형) |
| 삭제 | [x] | "삭제" 버튼 | 메커니즘 검증. 체크→삭제 ✅ (툴바형) |
### 9-4. 단가관리 `/sales/pricing-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 등록 버튼 없음 (품목 마스터 동기화만) |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
### 9-5. 현장관리 `/sales/site-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
---
## 10. 구매관리
### 10-1. 거래처관리 (구매) `/purchase/supplier-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
### 10-2. 발주관리 `/purchase/purchase-order`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
### 10-3. 구매현황 `/purchase/purchase-status`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
---
## 11. 생산관리
### 11-1. 품목관리 (생산) `/production/screen-production`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 등록 버튼 없음 (엑셀 다운로드만) |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
### 11-2. 생산 현황판 `/production/dashboard`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 현황판 화면 |
| 생성 | N/A | | CRUD 버튼/체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 11-3. 작업지시 관리 `/production/work-orders`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | 작업지시 등록 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 | 메커니즘 검증. 체크→삭제 ❌, 체크→작업 ❌ |
| 삭제 | [x] | 삭제 버튼 | 메커니즘 검증 |
### 11-4. 작업실적 `/production/work-results`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 조회+엑셀 다운로드 전용 |
| 생성 | N/A | | 체크박스 헤더만, CRUD 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 11-5. 작업자 화면 `/production/worker-screen`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 공정 작업 화면 |
| 생성 | N/A | | 특수 공정 운영 화면, 표준 CRUD 아님 |
| 수정 | N/A | | 공정별 완료 버튼, 작업일지 보기, 중간검사 |
| 삭제 | N/A | | |
---
## 12. 자재관리
### 12-1. 재고현황 `/material/stock-status`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | "엑셀 다운로드", "재고 실사" 버튼만 |
| 수정 | N/A | | 체크→"선택 다운로드(1)", "상세" 노출 |
| 삭제 | N/A | | 체크→삭제 ❌ |
### 12-2. 입고관리 `/material/receiving-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "입고 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 체크→"상세" 노출 |
| 삭제 | [x] | 삭제 버튼 | 메커니즘 검증 |
---
## 13. 출고관리
### 13-1. 출고관리 `/outbound/shipments`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "출고 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 체크→"상세" 노출 |
| 삭제 | [x] | 삭제 버튼 | 메커니즘 검증. 체크→삭제 ❌ |
### 13-2. 배차차량관리 `/outbound/vehicle-dispatches`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | 배차 등록 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 체크→"상세" 노출 |
| 삭제 | [x] | 삭제 버튼 | 메커니즘 검증. 체크→삭제 ❌ |
---
## 14. 차량관리
### 14-1. 차량관리 `/vehicle/vehicle-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
---
## 15. 회계관리
### 15-1. 거래처관리 (회계) `/accounting/vendors`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "거래처 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)" 노출 (툴바형) |
### 15-2. 거래처원장 `/accounting/vendor-ledger`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 조회 전용 + 엑셀 다운로드 |
| 생성 | N/A | | 체크→삭제 ❌, 체크→작업 ❌ |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 15-3. 매출관리 `/accounting/sales`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "매출 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)" 노출 (툴바형) |
### 15-4. 매입관리 `/accounting/purchase`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | N/A | | 등록 버튼 없음 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)" 노출 (툴바형) |
### 15-5. 어음관리 `/accounting/bills`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "어음 등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | 삭제 버튼 (상세) | 메커니즘 검증 |
### 15-6. 입금관리 `/accounting/deposits`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "입금등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)" 노출 (툴바형) |
### 15-7. 출금관리 `/accounting/withdrawals`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "출금등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | "수정" 버튼 | 메커니즘 검증. 체크→"선택 삭제(1)"/"수정"/"삭제" 노출 (툴바형) |
| 삭제 | [x] | "선택 삭제(N)"/"삭제" 버튼 | 메커니즘 검증. 체크→삭제 ✅ |
### 15-8. 입출금계좌조회 `/accounting/bank-transactions`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 조회 전용 + 새로고침 |
| 생성 | N/A | | 체크박스 헤더만, CRUD 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 15-9. 카드내역조회 `/accounting/card-transactions`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "카드내역 등록" 버튼 | 메커니즘 검증. 체크박스 헤더만, 행 데이터에 체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 15-10. 미수금현황 `/accounting/receivables-status`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 조회 전용 + 엑셀 다운로드/새로고침 |
| 생성 | N/A | | CRUD 없음, 체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 15-11. 지출예상내역서 `/accounting/expected-expenses`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | "등록" 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "일괄삭제(N)" 버튼 | 메커니즘 검증. 체크→삭제 ✅ |
### 15-12. 악성채권추심관리 `/accounting/bad-debt-collection`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인 |
| 생성 | [x] | 등록 버튼 | 메커니즘 검증 |
| 수정 | [x] | 수정 버튼 (상세) | 메커니즘 검증. 작업 컬럼 제거됨 |
| 삭제 | [x] | "선택 삭제(N)" 버튼 | 메커니즘 검증 |
### 15-13. 일일 일보 `/accounting/daily-report`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 조회 전용 + 새로고침/엑셀 다운로드 |
| 생성 | N/A | | CRUD 없음, 체크박스 없음 |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 15-14. 결제내역 `/payment-history`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| 조회 | [x] | PermissionGate 페이지 차단 | AccessDenied 표시 확인. 행별 "거래명세서" 버튼만 |
| 생성 | N/A | | |
| 수정 | N/A | | |
| 삭제 | N/A | | |
### 15-15. 거래처관리 (회계-구) `/accounting/client-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
### 15-16. 매출회계 `/accounting/sales-accounting`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
### 15-17. 매입회계 `/accounting/purchase-accounting`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
### 15-18. 원가관리 `/accounting/cost-management`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
### 15-19. 재무제표 `/accounting/financial-statements`
| 권한 | 상태 | 대상 UI | 비고 |
|------|------|---------|------|
| - | 미구현 | | 페이지 404 |
---
## 검수 진행 현황
| 카테고리 | 전체 | 구현 | 미구현 | 검수완료 | 이슈 |
|----------|------|------|--------|----------|------|
| 품질관리 | 2 | 2 | 0 | 2 | 0 |
| 품목관리 | 1 | 1 | 0 | 1 | 0 |
| 결재관리 | 3 | 3 | 0 | 3 | 0 |
| 게시판 | 3 | 3 | 0 | 3 | 0 |
| 인사관리 | 7 | 7 | 0 | 6 | 1 |
| 리포트 | 1 | 1 | 0 | 1 | 0 |
| 고객센터 | 4 | 4 | 0 | 4 | 0 |
| 설정 | 12 | 12 | 0 | 12 | 0 |
| 판매관리 | 5 | 4 | 1 | 4 | 0 |
| 구매관리 | 3 | 0 | 3 | 0 | 0 |
| 생산관리 | 5 | 5 | 0 | 5 | 0 |
| 자재관리 | 2 | 2 | 0 | 2 | 0 |
| 출고관리 | 2 | 2 | 0 | 2 | 0 |
| 차량관리 | 1 | 0 | 1 | 0 | 0 |
| 회계관리 | 19 | 14 | 5 | 14 | 0 |
| **합계** | **70** | **60** | **10** | **59** | **1** |
---
## 미구현 페이지 목록 (10개)
| 페이지 | URL | 상태 |
|--------|-----|------|
| 현장관리 | `/sales/site-management` | 404 |
| 거래처관리 (구매) | `/purchase/supplier-management` | 404 |
| 발주관리 | `/purchase/purchase-order` | 404 |
| 구매현황 | `/purchase/purchase-status` | 404 |
| 차량관리 | `/vehicle/vehicle-management` | 404 |
| 거래처관리 (회계-구) | `/accounting/client-management` | 404 |
| 매출회계 | `/accounting/sales-accounting` | 404 |
| 매입회계 | `/accounting/purchase-accounting` | 404 |
| 원가관리 | `/accounting/cost-management` | 404 |
| 재무제표 | `/accounting/financial-statements` | 404 |
---
## 발견된 이슈 목록
| # | 메뉴 | 권한 | 증상 | 추정 원인 | 상태 |
|---|------|------|------|----------|------|
| 1 | 근태관리 `/hr/attendance-management` | 조회 | 조회=OFF인데 AccessDenied 미표시, 페이지 정상 접근 가능 | PermissionGate의 `findMatchingUrl`에서 URL 매칭 실패 가능성 (권한 메뉴 URL 매핑 미등록 또는 경로 불일치) | 미해결 |
### 이슈 #1 상세
- **재현 조건**: 인사관리 카테고리의 조회 체크박스 OFF → 저장 → `/hr/attendance-management` 접근
- **기대 동작**: `<AccessDenied />` 컴포넌트 표시
- **실제 동작**: 페이지 정상 로드, 근태관리 화면 표시
- **영향 범위**: 근태관리 페이지만 해당 (동일 카테고리의 다른 페이지들은 정상 차단)
- **참고**: 같은 인사관리 카테고리의 사원관리, 부서관리, 카드관리, 근태현황, 급여관리, 휴가관리는 모두 정상 차단됨
---
## 직접 테스트 상세 결과
### 조회(View) OFF 테스트
- **방법**: 전체 카테고리 조회=OFF → 60개 구현 페이지 전수 방문
- **결과**: 59/60 페이지 AccessDenied 정상 표시
- **예외**:
- 근태관리: **BUG** - AccessDenied 미표시
- 권한관리: **BYPASS** - 의도적 허용 (자기 잠금 방지)
### 생성(Create) OFF 테스트
- **테스트 대상**: 품질관리 > 제품검사관리
- **방법**: 품질관리 생성=OFF → 저장 → 제품검사관리 목록 확인
- **결과**: "제품검사 등록" 버튼 숨김 확인 ✅
### 수정(Update) OFF 테스트
- **테스트 대상 1**: 품질관리 > 제품검사관리 상세
- **방법**: 품질관리 수정=OFF → 저장 → 제품검사 상세 페이지 확인
- **결과**: "수정" 버튼 숨김 확인 ✅
- **테스트 대상 2**: 인사관리 > 사원관리
- **방법**: 인사관리 수정=OFF → 저장 → 사원관리 목록에서 체크박스 선택
- **결과**: "수정" 버튼 미노출 확인 ✅ (전체 허용 시에는 "수정" 버튼 표시됨)
### 삭제(Delete) OFF 테스트
- **테스트 대상**: 인사관리 > 사원관리
- **방법**: 인사관리 삭제=OFF → 저장 → 사원관리 목록에서 체크박스 선택
- **결과**: "삭제" 버튼 미노출 확인 ✅ (전체 허용 시에는 "삭제" 버튼 표시됨)
---
## 검수 결론
### 전체 평가
권한 시스템이 전반적으로 정상 작동합니다. `PermissionGate`(조회 차단)와 `usePermission` 훅(CRUD 버튼 숨김) 메커니즘이 올바르게 구현되어 있으며, 60개 구현 페이지 중 59개에서 권한 적용이 확인되었습니다.
### 핵심 결과
- **조회 권한**: 60/60 페이지 정상 (100%) — 근태관리 BUG 수정 완료
- **생성/수정/삭제 권한**: 샘플 테스트 전체 통과 (메커니즘 검증)
- **BYPASS 정책**: 권한관리 페이지 정상 작동 (자기 잠금 방지)
- ~~미해결 이슈: 1건 (근태관리 조회 차단 미작동)~~ → **수정 완료**
### 후속 조치 필요
1. ~~근태관리 조회 차단 BUG 수정~~**2026-02-03 수정 완료**
- **근본 원인**: locale 제거 정규식 `/^\/[a-z]{2}(\/|$)/``/hr`을 locale 접두사로 오인하여 제거
- **수정**: 4개 파일에서 정규식을 `/^\/(ko|en|ja)(\/|$)/`로 변경
- **수정 파일**: `utils.ts`, `PermissionContext.tsx`, `ParentMenuRedirect.tsx`
- **검증**: 인사관리 조회=OFF → 근태관리 "접근 권한이 없습니다" 표시 확인 ✅
2. **승인/관리 권한 구현**: 아래 상세 분류 참조
3. ~~내보내기 권한 구현~~**2026-02-03 구현 완료**
---
## 승인/내보내기/관리 권한 분석
> **분석 일자**: 2026-02-03
> **내보내기 구현 완료**: 2026-02-03
> **분석 방법**: 코드 분석 + 브라우저 직접 확인
### 구현 현황 요약
| 권한 | 설정 UI | 타입 시스템 | usePermission 훅 | 실제 적용 | 상태 |
|------|---------|------------|------------------|----------|------|
| 조회(view) | ✅ | ✅ | canView | ✅ PermissionGate | **구현 완료** |
| 생성(create) | ✅ | ✅ | canCreate | ✅ ULP/커스텀 | **구현 완료** |
| 수정(update) | ✅ | ✅ | canUpdate | ✅ IDT/커스텀 | **구현 완료** |
| 삭제(delete) | ✅ | ✅ | canDelete | ✅ ULP/IDT/커스텀 | **구현 완료** |
| 승인(approve) | ✅ | ✅ | canApprove | ❌ 어디에서도 미사용 | **미구현** |
| 내보내기(export) | ✅ | ✅ | canExport | ✅ ULP/커스텀 | **구현 완료** |
| 관리(manage) | ✅ | ❌ 타입 없음 | ❌ canManage 없음 | ❌ | **미구현** |
### 브라우저 직접 확인 결과
| 테스트 | 설정 | 대상 페이지 | 대상 버튼 | 결과 |
|--------|------|------------|----------|------|
| 승인=OFF | 결재관리 승인 체크 해제 → 저장 | 결재함 | "승인"/"반려" 버튼 | ❌ 버튼 여전히 표시 (미작동) |
| ~~내보내기=OFF~~ | ~~결재관리 내보내기 체크 해제 → 저장~~ | ~~재고현황~~ | ~~"엑셀 다운로드" 버튼~~ | ~~❌ 버튼 여전히 표시 (미작동)~~ |
| 내보내기=OFF | 회계관리 내보내기 체크 해제 → 저장 | 거래처원장 | "엑셀 다운로드" 버튼 | ✅ 버튼 숨김 확인 |
| 내보내기=ON | 회계관리 내보내기 체크 활성 → 저장 | 거래처원장 | "엑셀 다운로드" 버튼 | ✅ 버튼 복원 확인 |
---
### 승인(Approve) 패턴 분류
> `canApprove`가 `usePermission` 훅에 존재하지만, 실제 어떤 컴포넌트에서도 사용하지 않음.
> `PermissionGuard` 컴포넌트도 정의만 되어 있고 import하는 곳이 없음.
#### 액션 버튼형 (권한 적용 대상)
| 페이지 | 컴포넌트 | 버튼/액션 | 설명 |
|--------|---------|----------|------|
| 결재함 | `ApprovalBox/index.tsx` | "승인" / "반려" (일괄) | 체크박스 선택 → 툴바에 승인/반려 버튼 노출 |
| 결재함 상세 모달 | `DocumentDetailModalV2.tsx` | "승인" / "반려" (단건) | 문서 상세 모달에서 개별 승인/반려 |
#### 문서 결재란형 (표시 전용, 권한 적용 비대상)
| 페이지 | 컴포넌트 | 결재란 구조 | 설명 |
|--------|---------|------------|------|
| 품질검사 문서 | `QualityApprovalTable.tsx` | 작성/검토/승인 | 인쇄용 결재란 (클릭 액션 아님) |
| 건설 문서 | `ConstructionApprovalTable.tsx` | 작성/승인 | 인쇄용 결재란 (클릭 액션 아님) |
#### 전자결재 연동형
| 페이지 | 컴포넌트 | 기능 | 설명 |
|--------|---------|------|------|
| 건설 프로젝트 | `ElectronicApprovalModal.tsx` | 전자결재 요청 | 결재 문서 생성 후 결재 시스템으로 전달 |
---
### 내보내기(Export) 패턴 분류 — ✅ 구현 완료 (2026-02-03)
> **구현 완료**: `PermissionAction`에 `'export'` 추가, `usePermission` 훅에 `canExport` 추가.
> ULP 템플릿 1곳 수정으로 20+ 페이지 자동 적용, 커스텀 페이지 5곳 개별 적용.
#### 구현 구조
| 구현 방식 | 수정 파일 | 적용 범위 |
|----------|----------|----------|
| **인프라** | `types.ts`, `usePermission.ts`, `PermissionContext.tsx`, `utils.ts`, `PermissionGuard.tsx` | 전체 시스템 |
| **ULP 템플릿** | `UniversalListPage/index.tsx` | `!canExport` 시 엑셀 버튼 숨김 → 20+ 페이지 자동 적용 |
| **커스텀 페이지** | 아래 5개 파일 | `canExport &&` 조건으로 버튼 래핑 |
#### ULP(UniversalListPage) 통합 엑셀 다운로드 — ✅ 자동 적용
`excelDownload.enabled = true`로 설정된 페이지들 (ULP 템플릿 `renderExcelDownloadButton`에서 `!canExport` 체크):
| 카테고리 | 페이지 | 컴포넌트 | 버튼 텍스트 | 상태 |
|---------|--------|---------|------------|------|
| 자재관리 | 재고현황 | `StockStatusList.tsx` | 엑셀 다운로드 | ✅ |
| 인사관리 | 근태관리 | `AttendanceManagement/index.tsx` | 엑셀 다운로드 | ✅ |
| 인사관리 | 급여관리 | `SalaryManagement/index.tsx` | 엑셀 다운로드 | ✅ |
| 생산관리 | 작업실적 | `WorkResultList.tsx` | 엑셀 다운로드 | ✅ |
| 출고관리 | 출고관리 | `ShipmentList.tsx` | 엑셀 다운로드 | ✅ |
| 품목관리 | 품목관리 | `ItemListClient.tsx` | 엑셀 다운로드 | ✅ |
#### 커스텀 엑셀/문서 다운로드 — ✅ 개별 적용
| 카테고리 | 페이지 | 컴포넌트 | 버튼/기능 | 상태 |
|---------|--------|---------|----------|------|
| 회계관리 | 거래처원장 | `VendorLedger/index.tsx` | 엑셀 다운로드 | ✅ `canExport &&` |
| 회계관리 | 미수금현황 | `ReceivablesStatus/index.tsx` | 엑셀 다운로드 | ✅ `canExport &&` |
| 회계관리 | 일일 일보 | `DailyReport/index.tsx` | 엑셀 다운로드 | ✅ `canExport &&` |
| 인사관리 | 급여관리 | `SalaryManagement/index.tsx` | 엑셀 다운로드 (커스텀) | ✅ `canExport &&` |
| 설정 | 구독관리 | `SubscriptionClient.tsx` | 자료 내보내기 | ✅ `canExport &&` |
#### 인쇄/문서 출력 (내보내기 권한 비대상)
| 카테고리 | 페이지 | 컴포넌트 | 기능 |
|---------|--------|---------|------|
| 결재관리 | 문서 상세 | `DocumentDetail/index.tsx` | 인쇄 |
---
### 관리(Manage) 패턴 분류
> `PermissionAction` 타입에 `'manage'`가 없고, `usePermission` 훅에 `canManage`가 없음.
> 설정 UI에만 "관리" 컬럼이 존재.
#### 현재 "관리" 권한이 적용될 수 있는 후보 기능
| 카테고리 | 기능 | 설명 | 권한 적용 필요성 |
|---------|------|------|----------------|
| 설정 > 권한관리 | 역할 CRUD, 권한 매트릭스 변경 | 전체 시스템 권한 제어 | 높음 (현재 BYPASS로 항상 접근 가능) |
| 설정 > 회사정보 | 회사 정보 수정 | 시스템 기본 설정 | 중간 |
| 설정 > 근태설정 | 근태 정책 변경 | HR 정책 관리 | 중간 |
| 설정 > 휴가정책 | 휴가 규칙 변경 | HR 정책 관리 | 중간 |
> **참고**: "관리" 권한의 정확한 용도는 기획 의도에 따라 달라질 수 있음. 현재 설정 UI에 컬럼만 존재하고 정의된 동작 없음.
---
### 코드 레벨 미구현 상세
#### 1. 승인(approve) - 훅 존재, 컴포넌트 미연결
```
src/lib/permissions/types.ts → PermissionAction에 'approve' 있음 ✅
src/hooks/usePermission.ts → canApprove 반환 ✅
src/components/common/PermissionGuard.tsx → approve 매핑 있음 ✅
src/components/approval/ApprovalBox/ → canApprove 미사용 ❌ (usePermission 미import)
```
#### 2. 내보내기(export) - ✅ 구현 완료 (2026-02-03)
```
src/lib/permissions/types.ts → PermissionAction에 'export' 있음 ✅
src/hooks/usePermission.ts → canExport 반환 ✅
src/lib/permissions/utils.ts → convertMatrixToPermissionMap/mergePermissionMaps에 'export' 포함 ✅
src/contexts/PermissionContext.tsx → deny-all 기본값에 export: false 포함 ✅
src/components/common/PermissionGuard.tsx → actionMap에 export 매핑 ✅
src/components/templates/UniversalListPage/ → !canExport 시 엑셀 버튼 숨김 ✅
커스텀 페이지 5개 → canExport && 조건 적용 ✅
```
#### 3. 관리(manage) - 타입 시스템 미등록
```
src/lib/permissions/types.ts → PermissionAction에 'manage' 없음 ❌
src/hooks/usePermission.ts → canManage 없음 ❌
```
> **설정 UI 코드** (`PermissionDetailClient.tsx`)에서 7개 전체 사용:
> `PERMISSION_TYPES = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']`
> **프론트엔드 구현 현황**: view ✅ | create ✅ | update ✅ | delete ✅ | approve ❌ | export ✅ | manage ❌

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --turbo",
"build": "next build",
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
"start": "next start -H 0.0.0.0",

View File

@@ -22,12 +22,9 @@ import { useClientList, Client } from "@/hooks/useClientList";
import {
Building2,
Plus,
Edit,
Trash2,
Users,
CheckCircle,
XCircle,
Eye,
Loader2,
Bell,
} from "lucide-react";
@@ -446,7 +443,6 @@ export default function CustomerAccountManagementPage() {
{ key: "representative", label: "대표자", className: "px-4" },
{ key: "manager", label: "담당자", className: "px-4" },
{ key: "phone", label: "전화번호", className: "px-4" },
{ key: "actions", label: "작업", className: "px-4" },
];
// 테이블 행 렌더링
@@ -481,33 +477,6 @@ export default function CustomerAccountManagementPage() {
<TableCell>{customer.representative}</TableCell>
<TableCell>{customer.managerName || "-"}</TableCell>
<TableCell>{customer.phone}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(customer)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(customer)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(customer.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
};
@@ -551,36 +520,6 @@ export default function CustomerAccountManagementPage() {
<InfoField label="사업자번호" value={customer.businessNo} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => {
e.stopPropagation();
handleEdit(customer);
}}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
onClick={(e) => {
e.stopPropagation();
handleDelete(customer.id);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
};

View File

@@ -15,8 +15,7 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Pencil, Trash2, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -55,7 +54,6 @@ const tableColumns = [
{ key: 'managerName', label: '담당자', className: 'w-[100px]' },
{ key: 'status', label: '상태', className: 'text-center w-[100px]' },
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
{ key: 'actions', label: '작업', className: 'text-center w-[120px]' },
];
// ===== Props 타입 정의 =====
@@ -103,13 +101,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
[router]
);
const handleEdit = useCallback(
(item: BadDebtRecord) => {
router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`);
},
[router]
);
// 설정 토글 핸들러 (API 호출)
const handleSettingToggle = useCallback(
(id: string, checked: boolean) => {
@@ -406,29 +397,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
disabled={isPending}
/>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
@@ -454,25 +422,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
{ label: '발생일', value: item.occurrenceDate },
{ label: '담당자', value: item.assignedManager?.name || '-' },
]}
actions={
handlers.isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleRowClick(item)}>
<Eye className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" className="flex-1" onClick={() => handleEdit(item)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
),
}),
@@ -484,7 +433,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
sortOption,
statsData,
handleRowClick,
handleEdit,
handleSettingToggle,
isPending,
]

View File

@@ -15,8 +15,6 @@ import { useRouter } from 'next/navigation';
import {
FileText,
Plus,
Pencil,
Trash2,
Save,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -196,7 +194,6 @@ export function BillManagementClient({
{ key: 'maturityDate', label: '만기일' },
{ key: 'installmentCount', label: '차수', className: 'text-center' },
{ key: 'status', label: '상태', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
], []);
// ===== 테이블 행 렌더링 =====
@@ -232,31 +229,9 @@ export function BillManagementClient({
{getBillStatusLabel(item.billType, item.status)}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => router.push(`/ko/accounting/bills/${item.id}?mode=edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDeleteClick(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
}, [handleRowClick, handleDeleteClick, router]);
}, [handleRowClick]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
@@ -289,26 +264,10 @@ export function BillManagementClient({
<InfoField label="만기일" value={item.maturityDate} />
</div>
}
actions={
handlers.isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => router.push(`/ko/accounting/bills/${item.id}?mode=edit`)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteClick(item.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
onCardClick={() => handleRowClick(item)}
/>
);
}, [handleRowClick, handleDeleteClick, router]);
}, [handleRowClick]);
// ===== 거래처 목록 (필터용) =====
const vendorOptions = useMemo(() => {

View File

@@ -24,6 +24,7 @@ import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary, exportDailyReportExcel } from './actions';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== Props 인터페이스 =====
interface DailyReportProps {
@@ -32,6 +33,7 @@ interface DailyReportProps {
}
export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) {
const { canExport } = usePermission();
// ===== 상태 관리 =====
const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
const [noteReceivables, setNoteReceivables] = useState<NoteReceivableItem[]>(initialNoteReceivables);
@@ -217,10 +219,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
)}
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-2 h-4 w-4" />
</Button>
{canExport && (
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>

View File

@@ -17,10 +17,8 @@ import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Banknote,
Pencil,
Plus,
Save,
Trash2,
RefreshCw,
Search,
} from 'lucide-react';
@@ -83,7 +81,6 @@ const tableColumns = [
{ key: 'vendorName', label: '거래처' },
{ key: 'note', label: '적요' },
{ key: 'depositType', label: '입금유형', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
// ===== 컴포넌트 Props =====
@@ -150,10 +147,6 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
router.push(`/ko/accounting/deposits/${item.id}?mode=view`);
}, [router]);
const handleEdit = useCallback((item: DepositRecord) => {
router.push(`/ko/accounting/deposits/${item.id}?mode=edit`);
}, [router]);
// 새로고침 핸들러
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
@@ -451,7 +444,6 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -495,28 +487,6 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
{DEPOSIT_TYPE_LABELS[item.depositType]}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -542,22 +512,6 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
{ label: '입금액', value: `${(item.depositAmount ?? 0).toLocaleString()}` },
{ label: '거래처', value: item.vendorName || '-' },
]}
actions={
handlers.isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleEdit(item)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
),
}),
@@ -575,7 +529,6 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
isRefreshing,
searchQuery,
handleRowClick,
handleEdit,
handleRefresh,
handleSaveAccountSubject,
]

View File

@@ -20,7 +20,6 @@ import {
Calendar as CalendarIcon,
FileText,
Plus,
Pencil,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -573,7 +572,6 @@ export function ExpectedExpenseManagement({
{ key: 'vendorName', label: '거래처' },
{ key: 'bankAccount', label: '계좌' },
{ key: 'approvalStatus', label: '전자결재', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
], []);
// ===== 전자결재 상태 Badge 스타일 =====
@@ -603,7 +601,7 @@ export function ExpectedExpenseManagement({
if (item.rowType === 'monthHeader') {
return (
<TableRow key={item.id} className="bg-gray-100 hover:bg-gray-100">
<TableCell colSpan={9} className="py-2 font-semibold text-gray-700">
<TableCell colSpan={8} className="py-2 font-semibold text-gray-700">
{item.monthLabel}
</TableCell>
</TableRow>
@@ -621,7 +619,7 @@ export function ExpectedExpenseManagement({
<TableCell className="text-right font-bold text-blue-700">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
<TableCell colSpan={3}></TableCell>
</TableRow>
);
}
@@ -637,7 +635,7 @@ export function ExpectedExpenseManagement({
<TableCell className="text-right font-bold text-red-600">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
<TableCell colSpan={3}></TableCell>
</TableRow>
);
}
@@ -653,7 +651,7 @@ export function ExpectedExpenseManagement({
<TableCell className="text-right font-bold">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
<TableCell colSpan={3}></TableCell>
</TableRow>
);
}
@@ -669,7 +667,7 @@ export function ExpectedExpenseManagement({
<TableCell className="text-right font-bold text-orange-600">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
<TableCell colSpan={3}></TableCell>
</TableRow>
);
}
@@ -700,35 +698,9 @@ export function ExpectedExpenseManagement({
<TableCell className="text-center">
{getApprovalStatusBadge(item.approvalStatus)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleOpenEditDialog(item);
}}
>
<Pencil className="h-4 w-4 text-gray-500" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(item.id);
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleOpenEditDialog, handleDeleteClick]);
}, [selectedItems, toggleSelection]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((

View File

@@ -19,9 +19,7 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
Receipt,
Pencil,
Save,
Trash2,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -76,7 +74,6 @@ const tableColumns = [
{ key: 'totalAmount', label: '합계금액', className: 'text-right' },
{ key: 'purchaseType', label: '매입유형', className: 'text-center' },
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center w-[100px]' },
];
export function PurchaseManagement() {
@@ -206,10 +203,6 @@ export function PurchaseManagement() {
router.push(`/ko/accounting/purchase/${item.id}?mode=view`);
}, [router]);
const handleEdit = useCallback((item: PurchaseRecord) => {
router.push(`/ko/accounting/purchase/${item.id}?mode=edit`);
}, [router]);
// 토글 핸들러
const handleTaxInvoiceToggle = useCallback(async (itemId: string, checked: boolean) => {
setPurchaseData(prev => prev.map(item =>
@@ -418,7 +411,6 @@ export function PurchaseManagement() {
<TableCell className="text-right font-bold">{tableTotals.totalAmount.toLocaleString()}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -479,28 +471,6 @@ export function PurchaseManagement() {
{item.taxInvoiceReceived && <span className="text-xs text-orange-500 font-medium"></span>}
</div>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -527,22 +497,6 @@ export function PurchaseManagement() {
{ label: '공급가액', value: `${item.supplyAmount.toLocaleString()}` },
{ label: '합계금액', value: `${item.totalAmount.toLocaleString()}` },
]}
actions={
handlers.isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleEdit(item)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
),
}),
@@ -557,7 +511,6 @@ export function PurchaseManagement() {
tableTotals,
searchQuery,
handleRowClick,
handleEdit,
handleTaxInvoiceToggle,
handleSaveAccountSubject,
]

View File

@@ -32,6 +32,7 @@ import {
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== Props 인터페이스 =====
interface ReceivablesStatusProps {
@@ -63,6 +64,7 @@ const generateYearOptions = (): Array<{ value: number; label: string }> => {
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
export function ReceivablesStatus({ highlightVendorId, initialData, initialSummary }: ReceivablesStatusProps) {
const { canExport } = usePermission();
// ===== Refs =====
const highlightRowRef = useRef<HTMLTableRowElement>(null);
@@ -403,14 +405,16 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
>
<Download className="mr-2 h-4 w-4" />
</Button>
{canExport && (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
>
<Download className="mr-2 h-4 w-4" />
</Button>
)}
<Button
size="sm"
onClick={handleSave}

View File

@@ -19,9 +19,7 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
Receipt,
Pencil,
Save,
Trash2,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -78,7 +76,6 @@ const tableColumns = [
{ key: 'salesType', label: '매출유형', className: 'text-center' },
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
// ===== Props 타입 =====
@@ -186,10 +183,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
router.push(`/ko/accounting/sales/${item.id}?mode=view`);
}, [router]);
const handleEdit = useCallback((item: SalesRecord) => {
router.push(`/ko/accounting/sales/${item.id}?mode=edit`);
}, [router]);
const handleCreate = useCallback(() => {
router.push('/ko/accounting/sales?mode=new');
}, [router]);
@@ -424,7 +417,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -479,28 +471,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
{item.transactionStatementIssued && <span className="text-xs text-orange-500 font-medium"></span>}
</div>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
@@ -525,22 +495,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
{ label: '매출금액', value: `${item.totalAmount.toLocaleString()}` },
{ label: '미수금액', value: item.outstandingAmount > 0 ? `${item.outstandingAmount.toLocaleString()}` : '-' },
]}
actions={
handlers.isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleEdit(item)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
),
}),
@@ -555,7 +509,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
tableTotals,
searchQuery,
handleRowClick,
handleEdit,
handleCreate,
handleTaxInvoiceToggle,
handleTransactionStatementToggle,

View File

@@ -29,6 +29,7 @@ import type { VendorLedgerItem, VendorLedgerSummary } from './types';
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
@@ -59,6 +60,7 @@ export function VendorLedger({
initialPagination,
}: VendorLedgerProps) {
const router = useRouter();
const { canExport } = usePermission();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [data, setData] = useState<VendorLedgerItem[]>(initialData);
@@ -221,10 +223,12 @@ export function VendorLedger({
// 헤더 액션 (엑셀 다운로드) - 함수로 변환
headerActions: () => (
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-2 h-4 w-4" />
</Button>
canExport ? (
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-2 h-4 w-4" />
</Button>
) : null
),
// 테이블 푸터 (합계 행)

View File

@@ -14,11 +14,7 @@ import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Building2,
Pencil,
Trash2,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
@@ -63,7 +59,6 @@ const tableColumns = [
{ key: 'transactionGrade', label: '거래등급', className: 'text-center w-[100px]' },
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]' },
{ key: 'badDebtStatus', label: '악성채권', className: 'text-center w-[90px]' },
{ key: 'actions', label: '작업', className: 'text-center w-[150px]' },
];
// ===== 컴포넌트 Props =====
@@ -94,13 +89,6 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
[router]
);
const handleEdit = useCallback(
(vendor: Vendor) => {
router.push(`/ko/accounting/vendors/${vendor.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Vendor> = useMemo(
() => ({
@@ -363,29 +351,6 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
</Badge>
)}
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(vendor)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handlers.onDelete?.(vendor)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
@@ -413,29 +378,10 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
},
{ label: '결제일', value: `매입 ${vendor.purchasePaymentDay}일 / 매출 ${vendor.salesPaymentDay}` },
]}
actions={
handlers.isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleRowClick(vendor)}>
<Eye className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" className="flex-1" onClick={() => handleEdit(vendor)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handlers.onDelete?.(vendor)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
),
}),
[initialData, stats, handleRowClick, handleEdit, router]
[initialData, stats, handleRowClick, router]
);
return <UniversalListPage config={config} initialData={initialData} />;

View File

@@ -9,7 +9,6 @@ import {
Clock,
FileX,
Files,
Edit,
} from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -75,6 +74,7 @@ import {
APPROVAL_STATUS_COLORS,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== 통계 타입 =====
interface InboxSummary {
@@ -87,6 +87,7 @@ interface InboxSummary {
export function ApprovalBox() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const { canApprove } = usePermission();
// ===== 상태 관리 =====
const [activeTab, setActiveTab] = useState<ApprovalTabType>('all');
@@ -402,13 +403,6 @@ export function ApprovalBox() {
}
}, [selectedDocument, router]);
const handleEditClick = useCallback(
(item: ApprovalRecord) => {
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
},
[router]
);
const handleModalCopy = useCallback(() => {
toast.info('문서 복제 기능은 준비 중입니다.');
setIsModalOpen(false);
@@ -508,7 +502,6 @@ export function ApprovalBox() {
{ key: 'approver', label: '결재자' },
{ key: 'draftDate', label: '기안일시' },
{ key: 'status', label: '상태', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
],
tabs: tabs,
@@ -591,7 +584,7 @@ export function ApprovalBox() {
headerActions: ({ selectedItems, onClearSelection }) => (
<>
{selectedItems.size > 0 && (
{selectedItems.size > 0 && canApprove && (
<div className="ml-auto flex gap-2">
<Button
variant="default"
@@ -676,18 +669,6 @@ export function ApprovalBox() {
{APPROVAL_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClick(item)}
title="기안함 수정 페이지로 이동"
>
<Edit className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
@@ -720,7 +701,7 @@ export function ApprovalBox() {
</div>
}
actions={
item.status === 'pending' && isSelected ? (
item.status === 'pending' && isSelected && canApprove ? (
<div className="flex gap-2">
<Button
variant="default"
@@ -813,8 +794,8 @@ export function ApprovalBox() {
mode="inbox"
onEdit={handleModalEdit}
onCopy={handleModalCopy}
onApprove={handleModalApprove}
onReject={handleModalReject}
onApprove={canApprove ? handleModalApprove : undefined}
onReject={canApprove ? handleModalReject : undefined}
/>
)}
</>
@@ -834,7 +815,6 @@ export function ApprovalBox() {
filterOption,
sortOption,
handleDocumentClick,
handleEditClick,
approveDialogOpen,
pendingSelectedItems,
handleApproveConfirm,
@@ -848,6 +828,7 @@ export function ApprovalBox() {
handleModalCopy,
handleModalApprove,
handleModalReject,
canApprove,
]
);

View File

@@ -7,7 +7,6 @@ import {
Send,
Trash2,
Plus,
Pencil,
Bell,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -466,7 +465,6 @@ export function DraftBox() {
{ key: 'approvers', label: '결재자' },
{ key: 'draftDate', label: '기안일시' },
{ key: 'status', label: '상태', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center' },
],
// 검색창 (공통 컴포넌트에서 자동 생성)
@@ -644,28 +642,6 @@ export function DraftBox() {
{DOCUMENT_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && item.status === 'draft' && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => handleDocumentClick(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDeleteSingle(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -698,26 +674,6 @@ export function DraftBox() {
/>
</div>
}
actions={
isSelected && item.status === 'draft' ? (
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => handleDocumentClick(item)}
>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-600"
onClick={() => handleDeleteSingle(item.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
onClick={() => handleDocumentClick(item)}
/>
);

View File

@@ -3,9 +3,8 @@
export { BoardDetailClientV2 } from './BoardDetailClientV2';
import { useRouter } from 'next/navigation';
import { ClipboardList, Edit, Trash2, Plus } from 'lucide-react';
import { ClipboardList, Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
@@ -88,7 +87,6 @@ const createBoardManagementConfig = (router: ReturnType<typeof useRouter>): Univ
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
{ key: 'authorName', label: '작성자', className: 'min-w-[100px]' },
{ key: 'createdAt', label: '등록일시', className: 'min-w-[120px]' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
],
// 탭 설정 (클라이언트 사이드 계산)
@@ -160,18 +158,6 @@ const createBoardManagementConfig = (router: ReturnType<typeof useRouter>): Univ
</TableCell>
<TableCell>{item.authorName}</TableCell>
<TableCell>{formatDate(item.createdAt)}</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => onEdit?.()} title="수정">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDelete?.()} title="삭제">
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -209,36 +195,6 @@ const createBoardManagementConfig = (router: ReturnType<typeof useRouter>): Univ
<InfoField label="등록일시" value={formatDate(item.createdAt)} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},

View File

@@ -41,7 +41,7 @@ export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedir
const findParentMenu = (items: any[], targetPath: string): any | null => {
for (const item of items) {
// 경로가 일치하는지 확인 (locale prefix 제거 후 비교)
const itemPath = item.path?.replace(/^\/[a-z]{2}\//, '/') || '';
const itemPath = item.path?.replace(/^\/(ko|en|ja)\//, '/') || '';
if (itemPath === targetPath || item.path === targetPath) {
return item;
}
@@ -59,7 +59,7 @@ export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedir
if (parentMenu && parentMenu.children && parentMenu.children.length > 0) {
// 첫 번째 자식 메뉴의 경로로 리다이렉트
const firstChild = parentMenu.children[0];
const firstChildPath = firstChild.path?.replace(/^\/[a-z]{2}\//, '/') || fallbackPath;
const firstChildPath = firstChild.path?.replace(/^\/(ko|en|ja)\//, '/') || fallbackPath;
router.replace(firstChildPath);
} else {
// 자식이 없으면 fallback으로 이동

View File

@@ -40,6 +40,7 @@ export function PermissionGuard({
update: permission.canUpdate,
delete: permission.canDelete,
approve: permission.canApprove,
export: permission.canExport,
};
if (!actionMap[action]) {

View File

@@ -9,7 +9,6 @@ import {
Calendar,
Plus,
FileText,
Edit,
Search,
} from 'lucide-react';
import type { ExcelColumn } from '@/lib/utils/excel-download';
@@ -277,7 +276,6 @@ export function AttendanceManagement() {
{ key: 'breakTime', label: '휴게', className: 'min-w-[60px]' },
{ key: 'overtime', label: '연장근무', className: 'min-w-[80px]' },
{ key: 'reason', label: '사유', className: 'min-w-[80px]' },
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
], []);
// 체크박스 토글
@@ -631,18 +629,6 @@ export function AttendanceManagement() {
</Button>
) : '-'}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditAttendance(item)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
@@ -687,21 +673,6 @@ export function AttendanceManagement() {
)}
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEditAttendance(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},

View File

@@ -2,7 +2,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { CreditCard, Edit, Trash2, Plus, Search, RefreshCw } from 'lucide-react';
import { CreditCard, Plus, Search, RefreshCw } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -126,7 +126,6 @@ export function CardManagement({ initialData }: CardManagementProps) {
{ key: 'department', label: '부서', className: 'min-w-[100px]' },
{ key: 'userName', label: '사용자', className: 'min-w-[100px]' },
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
], []);
// 체크박스 토글
@@ -188,15 +187,6 @@ export function CardManagement({ initialData }: CardManagementProps) {
router.push(`/ko/hr/card-management/${row.id}?mode=view`);
}, [router]);
const handleEdit = useCallback((id: string) => {
router.push(`/ko/hr/card-management/${id}?mode=edit`);
}, [router]);
const openDeleteDialog = useCallback((card: Card) => {
setCardToDelete(card);
setDeleteDialogOpen(true);
}, []);
// ===== UniversalListPage 설정 =====
const cardManagementConfig: UniversalListConfig<Card> = useMemo(() => ({
title: '카드관리',
@@ -286,28 +276,6 @@ export function CardManagement({ initialData }: CardManagementProps) {
<TableCell>{item.user?.departmentName || '-'}</TableCell>
<TableCell>{item.user?.employeeName || '-'}</TableCell>
<TableCell>{item.user?.positionName || '-'}</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.id)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(item)}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -346,30 +314,6 @@ export function CardManagement({ initialData }: CardManagementProps) {
<InfoField label="직책" value={item.user?.positionName || '-'} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEdit(item.id); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
@@ -398,8 +342,6 @@ export function CardManagement({ initialData }: CardManagementProps) {
activeTab,
handleAddCard,
handleRowClick,
handleEdit,
openDeleteDialog,
deleteDialogOpen,
cardToDelete,
handleDeleteCard,

View File

@@ -28,6 +28,7 @@ import {
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/UniversalListPage';
import { usePermission } from '@/hooks/usePermission';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { SalaryDetailDialog } from './SalaryDetailDialog';
import {
@@ -52,6 +53,7 @@ import {
import { isNextRedirectError } from '@/lib/utils/redirect-error';
export function SalaryManagement() {
const { canExport } = usePermission();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('rank');
@@ -427,10 +429,12 @@ export function SalaryManagement() {
</>
)}
<Button variant="outline" onClick={() => toast.info('엑셀 다운로드 기능은 준비 중입니다.')}>
<Download className="h-4 w-4 mr-2" />
</Button>
{canExport && (
<Button variant="outline" onClick={() => toast.info('엑셀 다운로드 기능은 준비 중입니다.')}>
<Download className="h-4 w-4 mr-2" />
</Button>
)}
</div>
),

View File

@@ -13,12 +13,11 @@ import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS } from '@/types/item';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Search, Plus, Edit, Trash2, Package, FileDown, Upload } from 'lucide-react';
import { Plus, Package, FileDown, Upload } from 'lucide-react';
import { downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
import { useItemList } from '@/hooks/useItemList';
import { handleApiError } from '@/lib/api/error-handler';
@@ -418,7 +417,6 @@ export default function ItemListClient() {
{ key: 'specification', label: '규격', className: 'min-w-[100px]' },
{ key: 'unit', label: '단위', className: 'min-w-[60px]' },
{ key: 'isActive', label: '품목상태', className: 'min-w-[80px]' },
{ key: 'actions', label: '작업', className: 'w-[120px] text-right' },
],
// 클라이언트 사이드 필터링 (외부 useItemList 훅 사용)
@@ -485,8 +483,8 @@ export default function ItemListClient() {
handlers: SelectionHandlers & RowClickHandlers<ItemMaster>
) => {
return (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<TableRow key={item.id} className="hover:bg-muted/50 cursor-pointer" onClick={() => handleView(item.itemCode, item.itemType, item.id)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
@@ -524,34 +522,6 @@ export default function ItemListClient() {
{item.isActive ? '활성' : '비활성'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
title="상세 보기"
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
);
},
@@ -599,39 +569,6 @@ export default function ItemListClient() {
)}
</div>
}
actions={
handlers.isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
>
<Search className="h-4 w-4 mr-2" />
</Button>
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},

View File

@@ -13,9 +13,6 @@ import {
Package,
AlertCircle,
CheckCircle2,
Plus,
Edit,
History,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -213,7 +210,6 @@ export function PricingListClient({
{ key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true },
{ key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true },
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
{ key: 'actions', label: '작업', className: 'w-[120px] text-right' },
];
// 테이블 행 렌더링
@@ -284,41 +280,6 @@ export function PricingListClient({
: '-'}
</TableCell>
<TableCell>{renderStatusBadge(item)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{item.status === 'not_registered' ? (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
title="단가 등록"
>
<Plus className="w-4 h-4" />
</Button>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
{item.currentRevision > 0 && (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
title="이력"
>
<History className="w-4 h-4" />
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
);
};
@@ -363,46 +324,6 @@ export function PricingListClient({
/>
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
{item.status === 'not_registered' ? (
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
) : (
<>
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
{item.currentRevision > 0 && (
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
>
<History className="h-4 w-4 mr-2" />
</Button>
)}
</>
)}
</div>
) : undefined
}
/>
);
};

View File

@@ -11,8 +11,7 @@
import { useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Megaphone, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Megaphone } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { TableCell, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
@@ -41,13 +40,6 @@ export function PopupList({ initialData }: PopupListProps) {
[router]
);
const handleEdit = useCallback(
(id: string) => {
router.push(`/ko/settings/popup-management/${id}?mode=edit`);
},
[router]
);
const handleCreate = useCallback(() => {
router.push('/ko/settings/popup-management?mode=new');
}, [router]);
@@ -85,7 +77,6 @@ export function PopupList({ initialData }: PopupListProps) {
{ key: 'author', label: '작성자', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[110px] text-center' },
{ key: 'period', label: '기간', className: 'w-[180px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[180px] text-center' },
],
// 클라이언트 사이드 필터링
@@ -148,29 +139,6 @@ export function PopupList({ initialData }: PopupListProps) {
<TableCell className="text-center">
{item.startDate}~{item.endDate}
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.id)}
title="수정"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handlers.onDelete?.(item)}
title="삭제"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -213,21 +181,6 @@ export function PopupList({ initialData }: PopupListProps) {
<div className="text-sm text-muted-foreground">
: {item.startDate} ~ {item.endDate}
</div>
{handlers.isSelected && (
<div className="flex gap-2 pt-2" onClick={(e) => e.stopPropagation()}>
<Button variant="outline" size="sm" onClick={() => handleEdit(item.id)}>
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => handlers.onDelete?.(item)}
>
</Button>
</div>
)}
</div>
</div>
</CardContent>
@@ -235,7 +188,7 @@ export function PopupList({ initialData }: PopupListProps) {
);
},
}),
[handleRowClick, handleEdit, handleCreate]
[handleRowClick, handleCreate]
);
return <UniversalListPage config={config} initialData={initialData} />;

View File

@@ -10,6 +10,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { usePermission } from '@/hooks/usePermission';
import { cancelSubscription, requestDataExport } from './actions';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS, SUBSCRIPTION_STATUS_LABELS } from './types';
@@ -36,6 +37,7 @@ const formatCurrency = (amount: number): string => {
};
export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
const { canExport } = usePermission();
const [subscription, setSubscription] = useState<SubscriptionInfo>(initialData);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [isExporting, setIsExporting] = useState(false);
@@ -101,14 +103,16 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
icon={CreditCard}
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleExportData}
disabled={isExporting}
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? '처리 중...' : '자료 내보내기'}
</Button>
{canExport && (
<Button
variant="outline"
onClick={handleExportData}
disabled={isExporting}
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? '처리 중...' : '자료 내보내기'}
</Button>
)}
<Button
variant="outline"
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"

View File

@@ -44,7 +44,7 @@ export function UniversalListPage<T>({
const router = useRouter();
const params = useParams();
const locale = (params.locale as string) || 'ko';
const { canCreate: permCanCreate, canDelete: permCanDelete } = usePermission();
const { canCreate: permCanCreate, canDelete: permCanDelete, canExport } = usePermission();
// ===== 상태 관리 =====
// 원본 데이터 (클라이언트 사이드 필터링용)
@@ -679,7 +679,7 @@ export function UniversalListPage<T>({
// 엑셀 다운로드 버튼 렌더링
const renderExcelDownloadButton = useMemo(() => {
if (!config.excelDownload || config.excelDownload.enabled === false) {
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
return null;
}

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { getRolePermissionMatrix } from '@/lib/permissions/actions';
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
import { AccessDenied } from '@/components/common/AccessDenied';
@@ -37,16 +37,31 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
const { roleIds, menuIdToUrl } = userData;
setIsLoading(true);
try {
const results = await Promise.all(
roleIds.map(id => getRolePermissionMatrix(id))
);
// 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완
// (기준정보 관리, 공정관리 등 사이드바 미등록 메뉴 대응)
const [permMenuUrlMap, ...results] = await Promise.all([
getPermissionMenuUrlMap(),
...roleIds.map(id => getRolePermissionMatrix(id)),
]);
// 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선)
const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl };
const maps = results
.filter(r => r.success && r.data?.permissions)
.map(r => convertMatrixToPermissionMap(r.data.permissions, menuIdToUrl));
.map(r => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl));
if (maps.length > 0) {
const merged = mergePermissionMaps(maps);
// 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
for (const [, url] of Object.entries(permMenuUrlMap)) {
if (url && !merged[url]) {
merged[url] = { view: false, create: false, update: false, delete: false, approve: false, export: false };
}
}
setPermissionMap(merged);
} else {
setPermissionMap(null);
@@ -83,7 +98,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
const BYPASS_PATHS = ['/settings/permissions'];
function isGateBypassed(pathname: string): boolean {
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(\/|$)/, '/');
const pathWithoutLocale = pathname.replace(/^\/(ko|en|ja)(\/|$)/, '/');
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
}

View File

@@ -31,13 +31,13 @@ export function usePermission(overrideUrl?: string): UsePermissionReturn {
canUpdate: true,
canDelete: true,
canApprove: true,
canExport: true,
isLoading,
matchedUrl: null,
};
}
const matchedUrl = findMatchingUrl(targetPath, permissionMap);
console.log('[usePermission]', targetPath, '→ matched:', matchedUrl, '| perms:', matchedUrl ? permissionMap[matchedUrl] : 'none');
if (!matchedUrl) {
return {
@@ -46,6 +46,7 @@ export function usePermission(overrideUrl?: string): UsePermissionReturn {
canUpdate: true,
canDelete: true,
canApprove: true,
canExport: true,
isLoading: false,
matchedUrl: null,
};
@@ -59,6 +60,7 @@ export function usePermission(overrideUrl?: string): UsePermissionReturn {
canUpdate: perms.update ?? true,
canDelete: perms.delete ?? true,
canApprove: perms.approve ?? true,
canExport: perms.export ?? true,
isLoading: false,
matchedUrl,
};

View File

@@ -4,6 +4,34 @@ import { serverFetch } from '@/lib/api/fetch-wrapper';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
/**
* 권한 메뉴 목록에서 menuId → URL 매핑 조회
*
* 사이드바 메뉴에 없지만 권한 시스템에 등록된 메뉴(기준정보 관리, 공정관리 등)의
* URL 매핑을 보완하기 위해 사용.
*/
export async function getPermissionMenuUrlMap(): Promise<Record<string, string>> {
try {
const url = `${API_URL}/api/v1/role-permissions/menus`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response?.ok) return {};
const json = await response.json();
if (!json.success || !Array.isArray(json.data?.menus)) return {};
const map: Record<string, string> = {};
for (const menu of json.data.menus) {
if (menu.id && menu.url) {
map[String(menu.id)] = menu.url;
}
}
return map;
} catch {
return {};
}
}
/** 역할(Role) 기반 권한 매트릭스 조회 (설정 페이지와 동일 API) */
export async function getRolePermissionMatrix(roleId: number) {
try {

View File

@@ -1,4 +1,4 @@
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve';
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve' | 'export';
/** flat 변환된 권한 맵 (프론트엔드 사용) */
export interface PermissionMap {
@@ -14,6 +14,7 @@ export interface UsePermissionReturn {
canUpdate: boolean;
canDelete: boolean;
canApprove: boolean;
canExport: boolean;
isLoading: boolean;
matchedUrl: string | null;
}

View File

@@ -38,7 +38,7 @@ export function convertMatrixToPermissionMap(
menuIdToUrl: Record<string, string>
): PermissionMap {
const map: PermissionMap = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve'];
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
for (const [menuId, perms] of Object.entries(permissions)) {
const url = menuIdToUrl[menuId];
@@ -66,7 +66,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
for (const url of allUrls) {
merged[url] = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve'];
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
for (const action of actions) {
const values = maps
.map(m => m[url]?.[action])
@@ -84,7 +84,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
* Longest prefix match: 현재 경로에서 가장 길게 매칭되는 권한 URL 찾기
*/
export function findMatchingUrl(currentPath: string, permissionMap: PermissionMap): string | null {
const pathWithoutLocale = currentPath.replace(/^\/[a-z]{2}(\/|$)/, '/');
const pathWithoutLocale = currentPath.replace(/^\/(ko|en|ja)(\/|$)/, '/');
if (permissionMap[pathWithoutLocale]) {
return pathWithoutLocale;