feat(WEB): 권한 관리 시스템 구현 및 상세 페이지 권한 통합
- PermissionContext, usePermission 훅, PermissionGuard 컴포넌트 신규 추가 - AccessDenied 접근 거부 페이지 추가 - permissions lib (체커, 매퍼, 타입) 구현 - BadDebtDetail, BoardDetail, LaborDetail, PricingDetail 등 상세 페이지 권한 적용 - ProcessDetail, StepDetail, ItemDetail, PermissionDetail 권한 연동 - RootProvider에 PermissionProvider 통합 - protected layout 권한 체크 추가 - Claude 프로젝트 설정 파일 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
25
.claude/hooks/typecheck-after-edit.sh
Executable file
25
.claude/hooks/typecheck-after-edit.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# PostToolUse Hook: Write/Edit 후 TypeScript 타입체크
|
||||
# exit 0 = 성공, exit 2 = 에러 (Claude에 피드백)
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# TypeScript 파일만 체크
|
||||
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 프로젝트 디렉토리로 이동
|
||||
cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0
|
||||
|
||||
# tsc 실행 (에러만 출력, 최대 20줄)
|
||||
RESULT=$(npx tsc --noEmit 2>&1 | head -20)
|
||||
|
||||
if [ -n "$RESULT" ] && echo "$RESULT" | grep -q "error TS"; then
|
||||
echo "TypeScript errors after editing $FILE_PATH:" >&2
|
||||
echo "$RESULT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
277
CLAUDE.md
Normal file
277
CLAUDE.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# SAM ERP 프로젝트 규칙
|
||||
|
||||
SAM 프로젝트(Next.js 프론트엔드) 전용 규칙. 범용 규칙은 `~/.claude/RULES.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
```yaml
|
||||
sam_project:
|
||||
frontend: sam_project/sam-next/sma-next-project/sam-react-prod # Next.js (현재)
|
||||
backend_api: sam_project/sam-api/sam-api # PHP Laravel
|
||||
design: sam_project/sam-design/sam-design # React 디자인 시스템
|
||||
특성: 인증 필수 폐쇄형 ERP 시스템 (SEO 불필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client Component 사용 원칙
|
||||
**Priority**: 🔴
|
||||
|
||||
### 배경
|
||||
- 폐쇄형 사이트 → SEO 불필요, 오히려 노출되면 안 됨
|
||||
- Server Component에서는 쿠키 수정(토큰 갱신) 불가
|
||||
|
||||
### 규칙
|
||||
- **Server Component 사용 금지**: `export default async function Page()` 패턴 금지
|
||||
- **Client Component 사용**: 모든 페이지는 `'use client'` 선언 필수
|
||||
- **데이터 로딩**: useEffect에서 Server Action 호출
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getData } from '@/components/.../actions';
|
||||
|
||||
export default function Page() {
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getData()
|
||||
.then(result => setData(result.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <div>로딩 중...</div>;
|
||||
return <Component initialData={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 패턴
|
||||
export default async function Page() {
|
||||
const result = await getData();
|
||||
return <Component initialData={result.data} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HttpOnly Cookie API Communication
|
||||
**Priority**: 🔴
|
||||
|
||||
- HttpOnly 쿠키는 JavaScript로 읽을 수 없음
|
||||
- **모든 인증 API 호출은 Next.js API route 프록시 필수**
|
||||
|
||||
```typescript
|
||||
// ✅ Next.js API Proxy
|
||||
// /src/app/api/proxy/[...path]/route.ts
|
||||
export async function GET(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||
const token = request.cookies.get('access_token')?.value;
|
||||
const response = await fetch(`${BACKEND_URL}/${params.path.join('/')}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// 프론트엔드에서는 프록시 호출
|
||||
const response = await fetch('/api/proxy/item-master/init');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기획서/스크린샷 기반 UI 구현 프로세스
|
||||
**Priority**: 🔴
|
||||
|
||||
### 기획서 Description 영역 처리
|
||||
기획서 스크린샷의 Description 영역(보통 오른쪽 검은 배경)은 **설명용**이며 UI에 구현하지 않음.
|
||||
빨간 원 번호, 설명 텍스트, 메타 정보 → 절대 UI에 추가 금지.
|
||||
|
||||
### 필수 5단계 프로세스
|
||||
|
||||
**1단계: Description 정독 및 요소 추출**
|
||||
- 각 번호(①②③...) 항목별 정확히 파악
|
||||
- 필터 조건, 테이블 헤더, 버튼/액션, 특수 기능 추출
|
||||
|
||||
**2단계: 구성 계획 작성 및 사용자 확인**
|
||||
🔴 구현 전 반드시 계획 제시 후 사용자 확인 필수. 확인 없이 구현 진행 절대 금지.
|
||||
|
||||
```markdown
|
||||
## [페이지명] 구성 계획
|
||||
### 필터 조건
|
||||
| 필터명 | 타입 | 옵션 | 기본값 |
|
||||
### 테이블 컬럼
|
||||
| 순서 | 컬럼명 | 설명 |
|
||||
### 특수 기능
|
||||
- [기능1]: [설명]
|
||||
```
|
||||
|
||||
**3단계: 기존 패턴 검색**
|
||||
```
|
||||
1순위: 동일 기능 컴포넌트 (예: "*Dashboard*.tsx")
|
||||
2순위: 유사 도메인 컴포넌트
|
||||
3순위: 공통 UI 컴포넌트 (src/components/ui/)
|
||||
```
|
||||
|
||||
**4단계: 구현** - 기획서 요소만, 임의 추가 절대 금지
|
||||
|
||||
**5단계: 검증 체크리스트**
|
||||
```markdown
|
||||
| 기획서 요소 | 구현 여부 | 비고 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Pattern Reuse
|
||||
**Priority**: 🔴
|
||||
|
||||
- 새 컴포넌트 만들기 전 프로젝트 내 유사 컴포넌트 검색 필수
|
||||
- 스크린샷만으로 추측 금지, 프로젝트 표준 우선
|
||||
|
||||
| 요소 | 확인 사항 |
|
||||
|------|----------|
|
||||
| 모달/다이얼로그 | 너비, 배경색, 헤더 구조, 버튼 배치 |
|
||||
| 문서/프린트 | 용지 스타일, 헤더/푸터, 결재라인 |
|
||||
| 폼 | 레이아웃, 필드 배치, 버튼 위치 |
|
||||
| 테이블/리스트 | 컬럼 구조, 체크박스, 페이지네이션 |
|
||||
|
||||
---
|
||||
|
||||
## Common Table Standards
|
||||
**Priority**: 🔴
|
||||
|
||||
### 필수 컬럼 구조
|
||||
- **체크박스** → **번호(1부터)** → **데이터 컬럼** → **작업 컬럼**
|
||||
- 작업 버튼: 체크박스 선택 시만 표시
|
||||
- 번호: `globalIndex` 사용 또는 `(currentPage - 1) * pageSize + index + 1`
|
||||
|
||||
---
|
||||
|
||||
## Document Table Merging (rowSpan/colSpan)
|
||||
**Priority**: 🔴
|
||||
|
||||
### 핵심: 구조 분석 → 코딩 (절대 순서 바꾸지 않음)
|
||||
|
||||
**1단계: 플랫 인덱스 맵** - 논리적 No가 아닌 실제 렌더링 행 수 기준
|
||||
```
|
||||
flatIdx 0: No.1 겉모양
|
||||
flatIdx 1: No.2 치수-두께 ← No.2 시작 (methodSpan: 3)
|
||||
flatIdx 2: No.2 치수-너비
|
||||
flatIdx 3: No.2 치수-길이 ← No.2 끝
|
||||
```
|
||||
|
||||
**2단계: 병합 범위 표기** - span은 병합 그룹의 첫 행에만
|
||||
|
||||
**3단계: Coverage Map 패턴**
|
||||
```typescript
|
||||
function buildCoverageMap(items, spanKey) {
|
||||
const map = {}; const covered = new Set();
|
||||
items.forEach((item, idx) => {
|
||||
const span = item[spanKey];
|
||||
if (span && span > 1) {
|
||||
map[idx] = span;
|
||||
for (let i = idx + 1; i < idx + span; i++) covered.add(i);
|
||||
}
|
||||
});
|
||||
return { map, covered };
|
||||
}
|
||||
// map에 있으면 → <td rowSpan={span}>
|
||||
// covered에 있으면 → skip
|
||||
// 둘 다 아니면 → 일반 <td>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Layout Standards
|
||||
**Priority**: 🟡
|
||||
|
||||
- **AuthenticatedLayout**: `<main>`에 패딩 없음
|
||||
- **PageLayout**: `p-3 md:p-6` 패딩 담당
|
||||
- **page.tsx**: 패딩 wrapper 금지 (이중 패딩 방지)
|
||||
|
||||
---
|
||||
|
||||
## Design Popup Policy
|
||||
**Priority**: 🟡
|
||||
|
||||
- `alert()`, `confirm()`, `prompt()` 사용 금지
|
||||
- Radix UI Dialog/AlertDialog 또는 `toast from 'sonner'` 사용
|
||||
|
||||
---
|
||||
|
||||
## Radix UI Select Controlled Mode Bug
|
||||
**Priority**: 🟡
|
||||
|
||||
빈 값('')으로 마운트 후 value 변경이 반영 안 되는 버그:
|
||||
```tsx
|
||||
// ✅ key prop으로 강제 리마운트
|
||||
<Select key={`${fieldKey}-${stringValue}`} value={stringValue} onValueChange={onChange}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Policy
|
||||
**Priority**: 🔴
|
||||
|
||||
- Claude가 직접 `npm run build` 실행 금지
|
||||
- 빌드 필요 시 사용자에게 "빌드 확인해주세요" 요청
|
||||
|
||||
---
|
||||
|
||||
## React → Next.js Migration Rules
|
||||
**Priority**: 🔴
|
||||
|
||||
### localStorage Access
|
||||
```typescript
|
||||
// ✅ Next.js Pattern
|
||||
const [data, setData] = useState(() => {
|
||||
if (typeof window === 'undefined') return defaultValue;
|
||||
const saved = localStorage.getItem('key');
|
||||
return saved ? JSON.parse(saved) : defaultValue;
|
||||
});
|
||||
```
|
||||
|
||||
### App Router Rules
|
||||
- Client Components: 'use client' for interactivity, state, browser APIs
|
||||
- Dynamic Import: `next/dynamic` with `ssr: false` for client-only components
|
||||
|
||||
---
|
||||
|
||||
## Large File Migration Workflow
|
||||
**Priority**: 🟡
|
||||
|
||||
**섹션당 6단계**: 구조 파악 → 기능 구현 → 기능 검증 → 스타일 파악 → 스타일 구현 → 스타일 검증
|
||||
|
||||
분할 전략: <1000줄 전체 | 1000-3000줄 3-4섹션 | >3000줄 1000줄 단위
|
||||
|
||||
---
|
||||
|
||||
## Backend API Analysis Policy
|
||||
**Priority**: 🟡
|
||||
|
||||
- Backend API 코드는 **분석만**, 직접 수정 안 함
|
||||
- 수정 필요 시 백엔드 요청 문서로 정리:
|
||||
```markdown
|
||||
## 백엔드 API 수정 요청
|
||||
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX)
|
||||
### 현재 문제: [설명]
|
||||
### 수정 요청: [내용]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test URL Documentation Rules
|
||||
**Priority**: 🟡
|
||||
|
||||
- 메인 페이지만 등록, 세부 페이지(상세/수정/등록) 제외
|
||||
- 간결한 목록 유지
|
||||
|
||||
---
|
||||
|
||||
## User Environment
|
||||
**Priority**: 🟢
|
||||
|
||||
- 스크린샷: 항상 바탕화면 `/Users/byeongcheolryu/Desktop/`
|
||||
- 파일명 패턴: `스크린샷 YYYY-MM-DD 오전/오후 HH.MM.SS.png`
|
||||
File diff suppressed because it is too large
Load Diff
163
claudedocs/[PLAN-2026-02-03] claude-config-optimization.md
Normal file
163
claudedocs/[PLAN-2026-02-03] claude-config-optimization.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Claude 설정 최적화 계획
|
||||
|
||||
**날짜**: 2026-02-03
|
||||
**목적**: 토큰 효율성 개선 + 규칙 분리 + 자동화 강화
|
||||
|
||||
---
|
||||
|
||||
## 배경 (왜 이 작업을 하는가?)
|
||||
|
||||
### 문제 발견
|
||||
- `~/.claude/` 에 **16개 MD 파일 (3,549줄, ~119KB)** 가 매 대화마다 전부 로드됨
|
||||
- SAM 프로젝트 전용 규칙이 글로벌에 있어 다른 프로젝트에도 불필요하게 전파
|
||||
- 비즈니스 분석용 파일(BUSINESS_PANEL, RESEARCH_CONFIG 등)이 일상 개발에도 로드
|
||||
- type-check 같은 프로세스가 MD 규칙으로만 존재 → 강제성 없음
|
||||
|
||||
### 기대 효과
|
||||
- 매 대화 토큰 사용량 감소 (불필요한 ~1,500줄 절약)
|
||||
- SAM 규칙과 범용 규칙의 명확한 분리
|
||||
- type-check 자동화로 실수 방지 강화
|
||||
|
||||
---
|
||||
|
||||
## 작업 계획
|
||||
|
||||
### 1단계: 프로젝트 CLAUDE.md 분리
|
||||
**상태**: 🔄 진행 중
|
||||
|
||||
**작업 내용**:
|
||||
- `~/.claude/RULES.md`에서 SAM 전용 규칙 추출
|
||||
- 프로젝트 루트에 `CLAUDE.md` 생성 (SAM 전용 규칙)
|
||||
- 글로벌 `RULES.md`는 범용 규칙만 남김
|
||||
|
||||
**SAM 전용으로 분류된 규칙들** (프로젝트 CLAUDE.md로 이동):
|
||||
| 섹션 | 이유 |
|
||||
|------|------|
|
||||
| Common Table Standards | SAM ERP 테이블 패턴 |
|
||||
| Document Table Merging | SAM 성적서/보고서 전용 |
|
||||
| Page Layout Standards | SAM AuthenticatedLayout 구조 |
|
||||
| Design Popup Policy | SAM Radix UI 스택 |
|
||||
| Build Policy | SAM 빌드 정책 |
|
||||
| Client Component 사용 원칙 | SAM 폐쇄형 ERP 특성 |
|
||||
| SAM Project Structure | SAM 경로 구조 |
|
||||
| Backend API Analysis Policy | SAM 백엔드 연동 |
|
||||
| Test URL Documentation Rules | SAM 테스트 URL |
|
||||
| User Environment Knowledge | 사용자 환경 특화 |
|
||||
| HttpOnly Cookie API Communication | SAM 인증 패턴 |
|
||||
| Radix UI Select Bug | SAM 기술 스택 이슈 |
|
||||
| React → Next.js Migration | SAM 마이그레이션 |
|
||||
| Large File Migration Workflow | SAM 마이그레이션 |
|
||||
| Component Pattern Reuse | SAM 컴포넌트 패턴 |
|
||||
| 기획서/스크린샷 기반 UI 구현 | SAM 기획서 프로세스 |
|
||||
|
||||
**글로벌에 남는 규칙들** (범용):
|
||||
| 섹션 | 이유 |
|
||||
|------|------|
|
||||
| Priority System | 모든 프로젝트 공통 |
|
||||
| Scope Discipline (일반 부분) | 모든 프로젝트 공통 |
|
||||
| Implementation Completeness | 모든 프로젝트 공통 |
|
||||
| Failure Investigation | 모든 프로젝트 공통 |
|
||||
| Professional Honesty | 모든 프로젝트 공통 |
|
||||
| Safety Rules | 모든 프로젝트 공통 |
|
||||
| Workflow & Session | 모든 프로젝트 공통 |
|
||||
| File & Code Organization | 모든 프로젝트 공통 |
|
||||
| Workspace Hygiene | 모든 프로젝트 공통 |
|
||||
| Git Rules | 모든 프로젝트 공통 |
|
||||
| Claude-Specific Rules | 모든 프로젝트 공통 |
|
||||
| Quick Reference | 모든 프로젝트 공통 |
|
||||
|
||||
---
|
||||
|
||||
### 2단계: 가끔 쓰는 MD 파일 임포트 해제
|
||||
**상태**: ⏳ 대기
|
||||
|
||||
**작업 내용**:
|
||||
- `~/.claude/CLAUDE.md`에서 `@` 임포트 줄 제거 (파일 자체는 유지)
|
||||
|
||||
**제거 대상 임포트** (일상 SAM 개발에 불필요):
|
||||
| 파일 | 줄 수 | 용도 |
|
||||
|------|-------|------|
|
||||
| @BUSINESS_PANEL_EXAMPLES.md | 278줄 | /sc:business-panel 전용 |
|
||||
| @BUSINESS_SYMBOLS.md | 211줄 | /sc:business-panel 전용 |
|
||||
| @MODE_Business_Panel.md | 334줄 | /sc:business-panel 전용 |
|
||||
| @RESEARCH_CONFIG.md | 445줄 | /sc:research 전용 |
|
||||
| @MODE_DeepResearch.md | 57줄 | /sc:research 전용 |
|
||||
| @MODE_Brainstorming.md | 43줄 | 가끔 사용 |
|
||||
| @MODE_Introspection.md | 38줄 | 거의 안 씀 |
|
||||
| @MODE_Token_Efficiency.md | 74줄 | 컨텍스트 압박 시만 |
|
||||
| **합계** | **~1,480줄** | **절약** |
|
||||
|
||||
**유지 대상 임포트**:
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| @PRINCIPLES.md | 핵심 엔지니어링 원칙 |
|
||||
| @RULES.md | 범용 행동 규칙 |
|
||||
| @GIT_POLICY.md | Git 워크플로우 |
|
||||
| @FLAGS.md | 모드 활성화 플래그 |
|
||||
| @MODE_Task_Management.md | 멀티스텝 작업 관리 |
|
||||
| @MODE_Orchestration.md | 도구 선택 최적화 |
|
||||
|
||||
---
|
||||
|
||||
### 3단계: Hooks 추가
|
||||
**상태**: ⏳ 대기
|
||||
|
||||
**작업 내용**:
|
||||
- `.claude/settings.local.json`에 PostToolUse hook 추가
|
||||
- Write/Edit 후 자동 `npx tsc --noEmit` 실행
|
||||
|
||||
---
|
||||
|
||||
### 4단계: 중복 제거
|
||||
**상태**: ⏳ 대기
|
||||
|
||||
**작업 내용**:
|
||||
- RULES.md의 "Git Rules" 섹션과 GIT_POLICY.md 간 중복 정리
|
||||
- RULES.md에는 "git 올려줘" 단축 명령 + 기본 원칙만 남김
|
||||
- 상세 브랜치 전략/커밋 규칙은 GIT_POLICY.md로 통합
|
||||
- COMPLEX_TASK_PROTOCOL.md는 RULES.md의 Checklist-Driven Development와 중복 → 정리
|
||||
|
||||
---
|
||||
|
||||
## 진행 기록
|
||||
|
||||
| 단계 | 상태 | 완료일 | 비고 |
|
||||
|------|------|--------|------|
|
||||
| 1단계: 프로젝트 CLAUDE.md 분리 | ✅ | 2026-02-03 | SAM 전용 16개 섹션 이동 |
|
||||
| 2단계: MD 임포트 해제 | ✅ | 2026-02-03 | 9개 파일 임포트 해제 (~1,759줄) |
|
||||
| 3단계: Hooks 추가 | ✅ | 2026-02-03 | PostToolUse typecheck hook |
|
||||
| 4단계: 중복 제거 | ✅ | 2026-02-03 | GIT_POLICY.md에서 87줄 중복 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 최종 결과
|
||||
|
||||
### 토큰 절약 수치
|
||||
```
|
||||
변경 전: 3,549줄 (전부 글로벌, 매 대화 로드)
|
||||
변경 후: 1,193줄 (글로벌 916 + 프로젝트 277)
|
||||
절약량: 2,356줄 (~66% 감소)
|
||||
```
|
||||
|
||||
### 다른 프로젝트에서의 효과
|
||||
```
|
||||
변경 전: 3,549줄 (SAM 규칙 포함)
|
||||
변경 후: 916줄 (범용 규칙만)
|
||||
절약량: 2,633줄 (~74% 감소)
|
||||
```
|
||||
|
||||
### 변경된 파일 목록
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `프로젝트/CLAUDE.md` | 신규 생성 (SAM 전용 규칙) |
|
||||
| `~/.claude/CLAUDE.md` | 임포트 9개 해제 |
|
||||
| `~/.claude/RULES.md` | SAM 규칙 제거 (1017→258줄) |
|
||||
| `~/.claude/GIT_POLICY.md` | 중복 섹션 제거 (394→307줄) |
|
||||
| `.claude/hooks/typecheck-after-edit.sh` | 신규 생성 |
|
||||
| `.claude/settings.local.json` | hooks 설정 추가 |
|
||||
|
||||
### Hook 동작 방식
|
||||
- **트리거**: Write/Edit 도구 사용 후
|
||||
- **대상**: .ts/.tsx 파일만
|
||||
- **실행**: `npx tsc --noEmit` (30초 타임아웃)
|
||||
- **에러 시**: Claude에 타입 에러 피드백 → 자동 수정 시도
|
||||
@@ -6,6 +6,7 @@ import { RootProvider } from '@/contexts/RootProvider';
|
||||
import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
||||
import { FCMProvider } from '@/contexts/FCMProvider';
|
||||
import { DevFillProvider, DevToolbar } from '@/components/dev';
|
||||
import { PermissionGate } from '@/contexts/PermissionContext';
|
||||
|
||||
/**
|
||||
* Protected Layout
|
||||
@@ -42,7 +43,9 @@ export default function ProtectedLayout({
|
||||
<ApiErrorProvider>
|
||||
<FCMProvider>
|
||||
<DevFillProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
<AuthenticatedLayout>
|
||||
<PermissionGate>{children}</PermissionGate>
|
||||
</AuthenticatedLayout>
|
||||
<DevToolbar />
|
||||
</DevFillProvider>
|
||||
</FCMProvider>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from './types';
|
||||
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface BadDebtDetailProps {
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
@@ -95,6 +96,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
|
||||
|
||||
export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProps) {
|
||||
const router = useRouter();
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
@@ -346,12 +348,16 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -365,7 +371,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode]);
|
||||
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode, canUpdate, canDelete]);
|
||||
|
||||
// 입력 필드 렌더링 헬퍼
|
||||
const renderField = (
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
|
||||
interface BoardDetailProps {
|
||||
board: Board;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷
|
||||
@@ -100,14 +100,18 @@ export function BoardDetail({ board, onEdit, onDelete }: BoardDetailProps) {
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ErrorCard } from '@/components/ui/error-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
type DetailMode = 'view' | 'edit' | 'create';
|
||||
|
||||
@@ -40,6 +41,7 @@ const generateBoardCode = (): string => {
|
||||
export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
@@ -268,8 +270,8 @@ export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV
|
||||
<>
|
||||
<BoardDetail
|
||||
board={boardData}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onEdit={canUpdate ? handleEdit : undefined}
|
||||
onDelete={canDelete ? handleDelete : undefined}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -45,6 +46,7 @@ export default function LaborDetailClient({
|
||||
}: LaborDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
|
||||
// 모드 상태
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
||||
@@ -410,18 +412,22 @@ export default function LaborDetailClient({
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEditMode}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -69,6 +70,7 @@ const initialFormData: FormData = {
|
||||
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
const [pricing, setPricing] = useState<Pricing | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
|
||||
@@ -403,18 +405,22 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
<div className="flex items-center gap-2">
|
||||
{isViewMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditMode && (
|
||||
|
||||
67
src/components/common/AccessDenied.tsx
Normal file
67
src/components/common/AccessDenied.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShieldOff, ArrowLeft, Home } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface AccessDeniedProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
showBackButton?: boolean;
|
||||
showHomeButton?: boolean;
|
||||
}
|
||||
|
||||
export function AccessDenied({
|
||||
title = '접근 권한이 없습니다',
|
||||
description = '이 페이지에 대한 접근 권한이 없습니다. 관리자에게 문의하세요.',
|
||||
showBackButton = true,
|
||||
showHomeButton = true,
|
||||
}: AccessDeniedProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg border border-border/20 bg-card/50 backdrop-blur">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-amber-500/20 to-orange-500/10 rounded-2xl flex items-center justify-center">
|
||||
<ShieldOff className="w-10 h-10 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-xl md:text-2xl font-bold text-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="text-center space-y-6">
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-2">
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showHomeButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
대시보드로 이동
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/common/PermissionGuard.tsx
Normal file
50
src/components/common/PermissionGuard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import type { PermissionAction } from '@/lib/permissions/types';
|
||||
|
||||
interface PermissionGuardProps {
|
||||
action: PermissionAction;
|
||||
/** 다른 메뉴 권한 체크 시 URL 직접 지정 (생략하면 현재 URL 자동 매칭) */
|
||||
url?: string;
|
||||
/** 권한 없을 때 대체 UI (기본: 렌더링 안 함) */
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼/영역 레벨 권한 제어 컴포넌트
|
||||
*
|
||||
* @example
|
||||
* // 현재 페이지 기준 (URL 자동매칭)
|
||||
* <PermissionGuard action="delete">
|
||||
* <Button variant="destructive">삭제</Button>
|
||||
* </PermissionGuard>
|
||||
*
|
||||
* // 다른 메뉴 권한 체크
|
||||
* <PermissionGuard action="approve" url="/approval/inbox">
|
||||
* <Button>승인</Button>
|
||||
* </PermissionGuard>
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
action,
|
||||
url,
|
||||
fallback = null,
|
||||
children,
|
||||
}: PermissionGuardProps) {
|
||||
const permission = usePermission(url);
|
||||
|
||||
const actionMap: Record<PermissionAction, boolean> = {
|
||||
view: permission.canView,
|
||||
create: permission.canCreate,
|
||||
update: permission.canUpdate,
|
||||
delete: permission.canDelete,
|
||||
approve: permission.canApprove,
|
||||
};
|
||||
|
||||
if (!actionMap[action]) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calenda
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -96,6 +97,7 @@ function getStorageUrl(path: string | undefined): string | null {
|
||||
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
@@ -625,13 +627,15 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { getProcessSteps } from './actions';
|
||||
import type { Process, ProcessStep } from '@/types/process';
|
||||
|
||||
@@ -28,6 +29,7 @@ interface ProcessDetailProps {
|
||||
export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
|
||||
// 단계 목록 상태
|
||||
const [steps, setSteps] = useState<ProcessStep[]>([]);
|
||||
@@ -325,10 +327,12 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import type { ProcessStep } from '@/types/process';
|
||||
|
||||
interface StepDetailProps {
|
||||
@@ -27,6 +28,7 @@ interface StepDetailProps {
|
||||
export function StepDetail({ step, processId }: StepDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(
|
||||
@@ -129,10 +131,12 @@ export function StepDetail({ step, processId }: StepDetailProps) {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
공정으로 돌아가기
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
resetPermissions,
|
||||
} from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermissionContext } from '@/contexts/PermissionContext';
|
||||
|
||||
// 플랫 배열을 트리 구조로 변환
|
||||
interface FlatMenuItem {
|
||||
@@ -128,6 +129,7 @@ const PERMISSION_LABELS_MAP: Record<PermissionType, string> = {
|
||||
export function PermissionDetailClient({ permissionId, isNew = false, mode = 'view' }: PermissionDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { reloadPermissions } = usePermissionContext();
|
||||
|
||||
// 역할 데이터
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
@@ -655,6 +657,15 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
reloadPermissions();
|
||||
toast.success('권한 정보가 저장되었습니다.');
|
||||
}}
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
권한 정보 저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
|
||||
143
src/contexts/PermissionContext.tsx
Normal file
143
src/contexts/PermissionContext.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { getRolePermissionMatrix } 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';
|
||||
|
||||
interface PermissionContextType {
|
||||
permissionMap: PermissionMap | null;
|
||||
isLoading: boolean;
|
||||
/** URL 지정 권한 체크 (특수 케이스용) */
|
||||
can: (url: string, action: PermissionAction) => boolean;
|
||||
/** 권한 데이터 다시 로드 (설정 변경 후 호출) */
|
||||
reloadPermissions: () => void;
|
||||
}
|
||||
|
||||
const PermissionContext = createContext<PermissionContextType>({
|
||||
permissionMap: null,
|
||||
isLoading: true,
|
||||
can: () => true,
|
||||
reloadPermissions: () => {},
|
||||
});
|
||||
|
||||
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [permissionMap, setPermissionMap] = useState<PermissionMap | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
const userData = getUserData();
|
||||
if (!userData || userData.roleIds.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { roleIds, menuIdToUrl } = userData;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
roleIds.map(id => getRolePermissionMatrix(id))
|
||||
);
|
||||
|
||||
const maps = results
|
||||
.filter(r => r.success && r.data?.permissions)
|
||||
.map(r => convertMatrixToPermissionMap(r.data.permissions, menuIdToUrl));
|
||||
|
||||
if (maps.length > 0) {
|
||||
const merged = mergePermissionMaps(maps);
|
||||
setPermissionMap(merged);
|
||||
} else {
|
||||
setPermissionMap(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Permission] 권한 로드 실패:', error);
|
||||
setPermissionMap(null);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 마운트 시 1회 로드
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
}, [loadPermissions]);
|
||||
|
||||
const can = useCallback((url: string, action: PermissionAction): boolean => {
|
||||
if (!permissionMap) return true;
|
||||
const perms = permissionMap[url];
|
||||
if (!perms) return true;
|
||||
return perms[action] ?? true;
|
||||
}, [permissionMap]);
|
||||
|
||||
return (
|
||||
<PermissionContext.Provider value={{ permissionMap, isLoading, can, reloadPermissions: loadPermissions }}>
|
||||
{children}
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 자기 잠금(self-lockout) 방지: 권한 설정 페이지는 항상 접근 허용
|
||||
*/
|
||||
const BYPASS_PATHS = ['/settings/permissions'];
|
||||
|
||||
function isGateBypassed(pathname: string): boolean {
|
||||
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(\/|$)/, '/');
|
||||
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGate: 레이아웃에 배치하여 모든 페이지 자동 보호
|
||||
*/
|
||||
export function PermissionGate({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { permissionMap, isLoading } = useContext(PermissionContext);
|
||||
|
||||
if (isLoading) return null;
|
||||
if (!permissionMap) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (isGateBypassed(pathname)) return <>{children}</>;
|
||||
|
||||
const matchedUrl = findMatchingUrl(pathname, permissionMap);
|
||||
|
||||
if (!matchedUrl) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const perms = permissionMap[matchedUrl];
|
||||
const canView = perms?.view ?? true;
|
||||
|
||||
if (!canView) {
|
||||
return <AccessDenied />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */
|
||||
function getUserData(): { roleIds: number[]; menuIdToUrl: Record<string, string> } | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem('user');
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
const roleIds = Array.isArray(parsed.roles)
|
||||
? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const menuIdToUrl = Array.isArray(parsed.menu)
|
||||
? buildMenuIdToUrlMap(parsed.menu)
|
||||
: {};
|
||||
|
||||
return { roleIds, menuIdToUrl };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const usePermissionContext = () => useContext(PermissionContext);
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { AuthProvider } from './AuthContext';
|
||||
import { PermissionProvider } from './PermissionContext';
|
||||
import { ItemMasterProvider } from './ItemMasterContext';
|
||||
|
||||
/**
|
||||
@@ -9,7 +10,8 @@ import { ItemMasterProvider } from './ItemMasterContext';
|
||||
*
|
||||
* 현재 사용 중인 Context:
|
||||
* 1. AuthContext - 사용자/인증 (2개 상태)
|
||||
* 2. ItemMasterContext - 품목관리 (13개 상태)
|
||||
* 2. PermissionContext - 권한 관리 (URL 자동매칭)
|
||||
* 3. ItemMasterContext - 품목관리 (13개 상태)
|
||||
*
|
||||
* 미사용 Context (contexts/_unused/로 이동됨):
|
||||
* - FacilitiesContext, AccountingContext, HRContext, ShippingContext
|
||||
@@ -18,9 +20,11 @@ import { ItemMasterProvider } from './ItemMasterContext';
|
||||
export function RootProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ItemMasterProvider>
|
||||
{children}
|
||||
</ItemMasterProvider>
|
||||
<PermissionProvider>
|
||||
<ItemMasterProvider>
|
||||
{children}
|
||||
</ItemMasterProvider>
|
||||
</PermissionProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/hooks/usePermission.ts
Normal file
65
src/hooks/usePermission.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePermissionContext } from '@/contexts/PermissionContext';
|
||||
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||
import type { UsePermissionReturn } from '@/lib/permissions/types';
|
||||
|
||||
/**
|
||||
* URL 자동매칭 권한 훅
|
||||
*
|
||||
* 인자 없이 호출하면 현재 URL 기반 자동 매칭.
|
||||
* 특수 케이스에서 URL 직접 지정 가능.
|
||||
*
|
||||
* @example
|
||||
* // 자동 매칭 (대부분의 경우)
|
||||
* const { canView, canCreate, canUpdate, canDelete } = usePermission();
|
||||
*
|
||||
* // URL 직접 지정 (다른 메뉴 권한 체크 시)
|
||||
* const { canApprove } = usePermission('/approval/inbox');
|
||||
*/
|
||||
export function usePermission(overrideUrl?: string): UsePermissionReturn {
|
||||
const pathname = usePathname();
|
||||
const { permissionMap, isLoading } = usePermissionContext();
|
||||
|
||||
const targetPath = overrideUrl || pathname;
|
||||
|
||||
if (isLoading || !permissionMap) {
|
||||
return {
|
||||
canView: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
canApprove: true,
|
||||
isLoading,
|
||||
matchedUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const matchedUrl = findMatchingUrl(targetPath, permissionMap);
|
||||
console.log('[usePermission]', targetPath, '→ matched:', matchedUrl, '| perms:', matchedUrl ? permissionMap[matchedUrl] : 'none');
|
||||
|
||||
if (!matchedUrl) {
|
||||
return {
|
||||
canView: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
canApprove: true,
|
||||
isLoading: false,
|
||||
matchedUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const perms = permissionMap[matchedUrl] || {};
|
||||
|
||||
return {
|
||||
canView: perms.view ?? true,
|
||||
canCreate: perms.create ?? true,
|
||||
canUpdate: perms.update ?? true,
|
||||
canDelete: perms.delete ?? true,
|
||||
canApprove: perms.approve ?? true,
|
||||
isLoading: false,
|
||||
matchedUrl,
|
||||
};
|
||||
}
|
||||
30
src/lib/permissions/actions.ts
Normal file
30
src/lib/permissions/actions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
'use server';
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
/** 역할(Role) 기반 권한 매트릭스 조회 (설정 페이지와 동일 API) */
|
||||
export async function getRolePermissionMatrix(roleId: number) {
|
||||
try {
|
||||
const url = `${API_URL}/api/v1/roles/${roleId}/permissions/matrix`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error) {
|
||||
console.error('[Permission Action] serverFetch error:', error);
|
||||
return { success: false, data: null };
|
||||
}
|
||||
|
||||
if (!response?.ok) {
|
||||
console.error('[Permission Action] HTTP 에러:', response?.status, response?.statusText);
|
||||
return { success: false, data: null };
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json;
|
||||
} catch (err) {
|
||||
console.error('[Permission Action] 예외:', err);
|
||||
return { success: false, data: null };
|
||||
}
|
||||
}
|
||||
19
src/lib/permissions/types.ts
Normal file
19
src/lib/permissions/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve';
|
||||
|
||||
/** flat 변환된 권한 맵 (프론트엔드 사용) */
|
||||
export interface PermissionMap {
|
||||
[url: string]: {
|
||||
[key in PermissionAction]?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** usePermission 훅 반환 타입 */
|
||||
export interface UsePermissionReturn {
|
||||
canView: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canApprove: boolean;
|
||||
isLoading: boolean;
|
||||
matchedUrl: string | null;
|
||||
}
|
||||
111
src/lib/permissions/utils.ts
Normal file
111
src/lib/permissions/utils.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { PermissionMap, PermissionAction } from './types';
|
||||
|
||||
interface SerializableMenuItem {
|
||||
id: string;
|
||||
path: string;
|
||||
children?: SerializableMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 메뉴 트리에서 menuId → URL 매핑 생성
|
||||
*/
|
||||
export function buildMenuIdToUrlMap(menus: SerializableMenuItem[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
|
||||
function traverse(items: SerializableMenuItem[]) {
|
||||
for (const item of items) {
|
||||
if (item.id && item.path) {
|
||||
map[item.id] = item.path;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
traverse(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(menus);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 페이지 API 응답(menuId 기반) → URL 기반 PermissionMap 변환
|
||||
*
|
||||
* API 응답: { permissions: { [menuId]: { view: true, create: false, ... } } }
|
||||
* 결과: { "/boards/free": { view: true, create: false, ... } }
|
||||
*/
|
||||
export function convertMatrixToPermissionMap(
|
||||
permissions: Record<string, Record<string, boolean>>,
|
||||
menuIdToUrl: Record<string, string>
|
||||
): PermissionMap {
|
||||
const map: PermissionMap = {};
|
||||
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve'];
|
||||
|
||||
for (const [menuId, perms] of Object.entries(permissions)) {
|
||||
const url = menuIdToUrl[menuId];
|
||||
if (!url) continue; // URL 매핑 없는 메뉴 스킵
|
||||
|
||||
map[url] = {};
|
||||
for (const action of actions) {
|
||||
// API는 허용된 권한만 포함, 누락된 action = 비허용(false)
|
||||
map[url][action] = perms[action] === true;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 역할 PermissionMap 병합 (Union: 하나라도 허용이면 허용)
|
||||
*/
|
||||
export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
|
||||
if (maps.length === 0) return {};
|
||||
if (maps.length === 1) return maps[0];
|
||||
|
||||
const merged: PermissionMap = {};
|
||||
const allUrls = new Set(maps.flatMap(m => Object.keys(m)));
|
||||
|
||||
for (const url of allUrls) {
|
||||
merged[url] = {};
|
||||
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve'];
|
||||
for (const action of actions) {
|
||||
const values = maps
|
||||
.map(m => m[url]?.[action])
|
||||
.filter((v): v is boolean => v !== undefined);
|
||||
if (values.length > 0) {
|
||||
merged[url][action] = values.some(v => v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest prefix match: 현재 경로에서 가장 길게 매칭되는 권한 URL 찾기
|
||||
*/
|
||||
export function findMatchingUrl(currentPath: string, permissionMap: PermissionMap): string | null {
|
||||
const pathWithoutLocale = currentPath.replace(/^\/[a-z]{2}(\/|$)/, '/');
|
||||
|
||||
if (permissionMap[pathWithoutLocale]) {
|
||||
return pathWithoutLocale;
|
||||
}
|
||||
|
||||
const segments = pathWithoutLocale.split('/').filter(Boolean);
|
||||
for (let i = segments.length; i > 0; i--) {
|
||||
const prefix = '/' + segments.slice(0, i).join('/');
|
||||
if (permissionMap[prefix]) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD 라우트에서 현재 액션 추론
|
||||
*/
|
||||
export function inferActionFromPath(path: string): PermissionAction {
|
||||
if (path.endsWith('/new') || path.endsWith('/create')) return 'create';
|
||||
if (path.endsWith('/edit')) return 'update';
|
||||
return 'view';
|
||||
}
|
||||
Reference in New Issue
Block a user