diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 25b7b0f6..874a6bee 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1165,3 +1165,198 @@ ### 다음 단계: - [ ] 커밋 및 문서화 --- + +## 2025-11-26 (화) - 개인 권한 관리 구현 및 permission_overrides 테이블 통일 + +### 주요 작업 +- 개인 권한 관리 (UserPermission) 기능 완전 구현 +- 기존 model_has_permissions 테이블 → permission_overrides 테이블로 통일 +- API AccessService와 동일한 테이블 구조 사용 + +### 추가된 파일: +- `app/Http/Controllers/UserPermissionController.php` - Blade 화면 컨트롤러 +- `app/Http/Controllers/Api/Admin/UserPermissionController.php` - HTMX API 컨트롤러 +- `resources/views/user-permissions/index.blade.php` - 메인 페이지 +- `resources/views/user-permissions/partials/permission-matrix.blade.php` - 권한 매트릭스 +- `resources/views/user-permissions/partials/empty-state.blade.php` - 빈 상태 화면 + +### 수정된 파일: +- `app/Services/UserPermissionService.php` - model_has_permissions → permission_overrides 테이블 사용 +- `app/Services/DepartmentPermissionService.php` - model_has_permissions → permission_overrides 테이블 사용 +- `routes/web.php` - 개인 권한 관리 라우트 추가 +- `routes/api.php` - 개인 권한 관리 API 라우트 추가 +- `resources/views/partials/sidebar.blade.php` - 개인 권한 관리 메뉴 링크 연결 + +### 기술 상세: +**permission_overrides 테이블 통일:** +- `model_type`: 폴리모픽 타입 (User, Department) +- `model_id`: 대상 ID +- `permission_id`: 권한 ID +- `effect`: 0=DENY, 1=ALLOW (현재 ALLOW만 관리) +- `effective_from`, `effective_to`: 유효 기간 지원 +- Soft delete 지원 (deleted_at, deleted_by) + +**권한 체크 우선순위 (API AccessService):** +1. 개인 DENY → 거부 +2. Role 권한 (Spatie can) → 허용 +3. 부서 ALLOW → 허용 +4. 개인 ALLOW → 허용 +5. 기본 → 거부 + +### 코드 품질: +- ✅ PHP 문법 검사 통과 +- ✅ Pint 포맷팅 통과 + +--- + +## 2025-11-26 (화) - 역할/부서 권한 관리 테넌트별 그룹핑 + +### 주요 작업 +- "전체" 테넌트 선택 시 역할/부서가 혼합 표시되는 문제 해결 +- 테넌트별로 그룹핑하여 가독성 향상 + +### 수정된 파일: +- `app/Http/Controllers/RolePermissionController.php` - 테넌트별 역할 그룹핑 로직 추가 +- `app/Http/Controllers/DepartmentPermissionController.php` - 테넌트별 부서 그룹핑 로직 추가 +- `resources/views/role-permissions/index.blade.php` - 테넌트별 섹션 헤더 및 그룹핑 UI +- `resources/views/department-permissions/index.blade.php` - 테넌트별 섹션 헤더 및 그룹핑 UI + +### 기술 구현: +**Controller 로직:** +```php +if ($tenantId && $tenantId !== 'all') { + // 특정 테넌트 선택 시 기존 방식 + $roles = $rolesQuery->where('tenant_id', $tenantId)->get(); + $rolesByTenant = null; +} else { + // 전체 선택 시 테넌트별 그룹핑 + $roles = $rolesQuery->get(); + $rolesByTenant = $roles->groupBy('tenant_id'); + $tenantIds = $rolesByTenant->keys()->filter()->toArray(); + $tenants = Tenant::whereIn('id', $tenantIds)->pluck('company_name', 'id'); +} +``` + +**View UI:** +- 전체 테넌트 선택 시: 테넌트별 섹션으로 구분 (회색 라벨) +- 선택된 역할/부서 표시: `[테넌트명] 역할명 역할` 형식 +- 기존 단일 테넌트 선택 시: 기존 UI 유지 + +### 이슈 해결: +- **문제**: `tenants` 테이블에 `name` 컬럼 없음 (SQL 에러) +- **원인**: 테넌트 테이블은 `company_name` 컬럼 사용 +- **해결**: `pluck('name', 'id')` → `pluck('company_name', 'id')` 변경 + +### Git 커밋: +- ✅ `f029d78` "역할/부서 권한 관리 페이지 테넌트별 그룹핑 기능 추가" + +### 문서화: +- ✅ `docs/[MNG-2025-11-26] role-department-permission-tenant-grouping.md` 작성 + +--- + +## 2025-11-26 (화) - 개인 권한 관리 3-state 토글 UI 구현 + +### 주요 작업 +- 개인 권한 관리에서 허용/거부/미설정 3단계 상태 지원 +- 체크박스 → 토글 버튼 UI로 변경 +- 권한 상태 순환: 미설정 → 허용 → 거부 → 미설정 + +### 수정된 파일: +- `app/Services/UserPermissionService.php` + - `getUserPermissionMatrix()`: 3-state 지원 (null/'allow'/'deny' 반환) + - `togglePermission()`: 3단계 순환 로직 (null→allow→deny→null) + - `allowAllPermissions()`: DENY 레코드도 ALLOW로 변경 + - `denyAllPermissions()`: ALLOW/DENY 모두 soft delete (미설정으로 초기화) +- `resources/views/user-permissions/partials/permission-matrix.blade.php` + - 체크박스 → 토글 버튼으로 변경 + - 3가지 상태별 아이콘 및 색상: + - 미설정: ➖ (회색) + - 허용: ✓ (녹색) + - 거부: ✕ (빨간색) + - 범례 추가 (하단) + +### UI/UX 변경사항: +**토글 버튼 디자인:** +``` +미설정: bg-gray-100 text-gray-400 (➖) +허용: bg-green-100 text-green-600 (✓) +거부: bg-red-100 text-red-600 (✕) +``` + +**토글 순환:** +``` +클릭 시: 미설정 → 허용 → 거부 → 미설정 (무한 순환) +``` + +**DB 상태:** +- 미설정: 레코드 없음 또는 soft delete (deleted_at) +- 허용: effect=1, deleted_at=null +- 거부: effect=0, deleted_at=null + +### API AccessService 권한 체크 우선순위: +1. 개인 DENY → 즉시 거부 +2. Role 권한 (Spatie can) → 허용 +3. 부서 ALLOW → 허용 +4. 개인 ALLOW → 허용 +5. 기본 → 거부 + +### 코드 품질: +- ✅ PHP 문법 검사 통과 +- ✅ Pint 포맷팅 통과 + +--- + +## 2025-11-26 (화) - 개인 권한 관리 통합 매트릭스 구현 + +### 주요 작업 +- 역할/부서/개인 권한을 통합하여 최종 유효 권한 표시 +- 권한 소스별 색상 구분 (역할=보라, 부서=파랑, 개인허용=녹색, 개인거부=빨강) +- 스마트 토글 로직 구현 (상속된 권한 오버라이드 지원) + +### 수정된 파일: +- `app/Services/UserPermissionService.php` - 역할/부서/개인 권한 통합 조회 + - `getRolePermissions()`: Spatie `model_has_roles` + `role_has_permissions` 조회 + - `getDepartmentPermissions()`: `permission_overrides` 테이블 (Department 모델타입) 조회 + - `getPersonalOverrides()`: `permission_overrides` 테이블 (User 모델타입) 조회 + - `getUserPermissionMatrix()`: 모든 권한 소스 통합, 소스 정보 반환 + - `togglePermission()`: 스마트 토글 로직 +- `resources/views/user-permissions/index.blade.php` - 사용자 ID 뱃지 스타일 개선 +- `resources/views/user-permissions/partials/permission-matrix.blade.php` - 5가지 상태 UI + +### 기술 구현: + +**권한 소스 우선순위 (표시용):** +1. 개인 DENY → 최우선 (빨간색) +2. 개인 ALLOW → 녹색 +3. 역할 권한 (Spatie) → 보라색 +4. 부서 권한 → 파란색 +5. 미설정 → 회색 + +**스마트 토글 로직:** +``` +- 역할/부서 권한 (개인 설정 없음) → 클릭 시 개인 DENY 추가 (오버라이드) +- 개인 DENY → 클릭 시 삭제 (상속된 권한 복원) +- 권한 없음 → 클릭 시 개인 ALLOW 추가 +- 개인 ALLOW → 클릭 시 개인 DENY로 변경 +``` + +**색상 코드:** +``` +보라색 (bg-purple-100): 역할에서 상속된 권한 +파란색 (bg-blue-100): 부서에서 상속된 권한 +녹색 (bg-green-100): 개인 허용 설정 +빨간색 (bg-red-100): 개인 거부 설정 +회색 (bg-gray-100): 미설정 +``` + +**UI 개선:** +- 사용자 선택 버튼: 이름 + 아이디 뱃지 (` ` 패딩) +- 선택된 사용자 표시: 테두리 스타일 (`border border-blue-400`) +- 범례 업데이트: 5가지 상태 모두 표시 + +### 코드 품질: +- ✅ PHP 문법 검사 통과 +- ✅ Pint 포맷팅 통과 + +--- diff --git a/app/Http/Controllers/Api/Admin/UserPermissionController.php b/app/Http/Controllers/Api/Admin/UserPermissionController.php new file mode 100644 index 00000000..2b67902e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/UserPermissionController.php @@ -0,0 +1,161 @@ +userPermissionService = $userPermissionService; + } + + /** + * 사용자의 tenant_id 조회 (세션이 'all'이거나 미선택일 때 사용) + */ + protected function getEffectiveTenantId(Request $request, ?int $userId = null): ?int + { + $sessionTenantId = session('selected_tenant_id'); + + // 세션에 특정 테넌트가 선택되어 있으면 그것을 사용 + if ($sessionTenantId && $sessionTenantId !== 'all') { + return (int) $sessionTenantId; + } + + // 'all'이거나 미선택일 때는 요청에서 tenant_id를 가져옴 + if ($request->has('tenant_id')) { + return (int) $request->input('tenant_id'); + } + + return null; + } + + /** + * 권한 매트릭스 조회 (사용자 변경 시 호출) + */ + public function getMatrix(Request $request) + { + $userId = $request->input('user_id'); + $guardName = $request->input('guard_name', 'api'); + + if (! $userId) { + return view('user-permissions.partials.empty-state'); + } + + // 사용자의 tenant_id로 메뉴 필터링 + $tenantId = $this->getEffectiveTenantId($request, $userId); + + // 메뉴 트리 조회 (테넌트 기준) + $menus = $this->userPermissionService->getMenuTree($tenantId); + + // 권한 매트릭스 조회 + $permissions = $this->userPermissionService->getUserPermissionMatrix($userId, $tenantId, $guardName); + + return view('user-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'userId' => $userId, + ]); + } + + /** + * 권한 토글 + */ + public function toggle(Request $request) + { + $userId = $request->input('user_id'); + $menuId = $request->input('menu_id'); + $permissionType = $request->input('permission_type'); + $guardName = $request->input('guard_name', 'api'); + $tenantId = $this->getEffectiveTenantId($request, $userId); + + $newValue = $this->userPermissionService->togglePermission( + $userId, + $menuId, + $permissionType, + $tenantId, + $guardName + ); + + // 전체 매트릭스 다시 로드 + $menus = $this->userPermissionService->getMenuTree($tenantId); + $permissions = $this->userPermissionService->getUserPermissionMatrix($userId, $tenantId, $guardName); + + return view('user-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'userId' => $userId, + ]); + } + + /** + * 전체 허용 + */ + public function allowAll(Request $request) + { + $userId = $request->input('user_id'); + $guardName = $request->input('guard_name', 'api'); + $tenantId = $this->getEffectiveTenantId($request, $userId); + + $this->userPermissionService->allowAllPermissions($userId, $tenantId, $guardName); + + // 전체 매트릭스 다시 로드 + $menus = $this->userPermissionService->getMenuTree($tenantId); + $permissions = $this->userPermissionService->getUserPermissionMatrix($userId, $tenantId, $guardName); + + return view('user-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'userId' => $userId, + ]); + } + + /** + * 전체 거부 + */ + public function denyAll(Request $request) + { + $userId = $request->input('user_id'); + $guardName = $request->input('guard_name', 'api'); + $tenantId = $this->getEffectiveTenantId($request, $userId); + + $this->userPermissionService->denyAllPermissions($userId, $tenantId, $guardName); + + // 전체 매트릭스 다시 로드 + $menus = $this->userPermissionService->getMenuTree($tenantId); + $permissions = $this->userPermissionService->getUserPermissionMatrix($userId, $tenantId, $guardName); + + return view('user-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'userId' => $userId, + ]); + } + + /** + * 기본 권한으로 초기화 (view만 허용) + */ + public function reset(Request $request) + { + $userId = $request->input('user_id'); + $guardName = $request->input('guard_name', 'api'); + $tenantId = $this->getEffectiveTenantId($request, $userId); + + $this->userPermissionService->resetToDefaultPermissions($userId, $tenantId, $guardName); + + // 전체 매트릭스 다시 로드 + $menus = $this->userPermissionService->getMenuTree($tenantId); + $permissions = $this->userPermissionService->getUserPermissionMatrix($userId, $tenantId, $guardName); + + return view('user-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'userId' => $userId, + ]); + } +} diff --git a/app/Http/Controllers/UserPermissionController.php b/app/Http/Controllers/UserPermissionController.php new file mode 100644 index 00000000..e2b78d4a --- /dev/null +++ b/app/Http/Controllers/UserPermissionController.php @@ -0,0 +1,42 @@ +userPermissionService = $userPermissionService; + } + + /** + * 사용자 권한 관리 메인 페이지 + */ + public function index(Request $request) + { + $tenantId = session('selected_tenant_id'); + + // 테넌트 미선택 또는 전체 선택 시 + if (! $tenantId || $tenantId === 'all') { + return view('user-permissions.index', [ + 'users' => collect(), + 'requireTenant' => true, + 'selectedTenantId' => $tenantId, + ]); + } + + // 특정 테넌트 선택 시: 해당 테넌트의 사용자 목록 조회 + $users = $this->userPermissionService->getUsersByTenant($tenantId); + + return view('user-permissions.index', [ + 'users' => $users, + 'requireTenant' => false, + 'selectedTenantId' => $tenantId, + ]); + } +} diff --git a/app/Services/DepartmentPermissionService.php b/app/Services/DepartmentPermissionService.php index 69f3cf59..6eb69983 100644 --- a/app/Services/DepartmentPermissionService.php +++ b/app/Services/DepartmentPermissionService.php @@ -15,7 +15,7 @@ class DepartmentPermissionService private array $permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']; /** - * 부서의 권한 매트릭스 조회 + * 부서의 권한 매트릭스 조회 (permission_overrides 테이블 사용) * * @param int $departmentId 부서 ID * @param int|null $tenantId 테넌트 ID @@ -24,18 +24,28 @@ class DepartmentPermissionService */ public function getDepartmentPermissionMatrix(int $departmentId, ?int $tenantId = null, string $guardName = 'api'): array { - $query = DB::table('model_has_permissions') - ->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id') - ->where('model_has_permissions.model_type', Department::class) - ->where('model_has_permissions.model_id', $departmentId) - ->where('permissions.guard_name', $guardName) - ->where('permissions.name', 'like', 'menu:%'); + $now = now(); + + $query = DB::table('permission_overrides as po') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->where('po.model_type', Department::class) + ->where('po.model_id', $departmentId) + ->where('po.effect', 1) // ALLOW만 조회 + ->where('p.guard_name', $guardName) + ->where('p.name', 'like', 'menu:%') + ->whereNull('po.deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }); if ($tenantId) { - $query->where('model_has_permissions.tenant_id', $tenantId); + $query->where('po.tenant_id', $tenantId); } - $departmentPermissions = $query->pluck('permissions.name')->toArray(); + $departmentPermissions = $query->pluck('p.name')->toArray(); $permissions = []; foreach ($departmentPermissions as $permName) { @@ -55,7 +65,7 @@ public function getDepartmentPermissionMatrix(int $departmentId, ?int $tenantId } /** - * 특정 메뉴의 특정 권한 토글 + * 특정 메뉴의 특정 권한 토글 (permission_overrides 테이블 사용) * * @param int $departmentId 부서 ID * @param int $menuId 메뉴 ID @@ -74,30 +84,75 @@ public function togglePermission(int $departmentId, int $menuId, string $permiss ['tenant_id' => null, 'created_by' => auth()->id()] ); - // 현재 권한 상태 확인 - $exists = DB::table('model_has_permissions') + $now = now(); + + // 현재 권한 상태 확인 (유효한 ALLOW 오버라이드가 있는지) + $exists = DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $departmentId) ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) ->exists(); if ($exists) { - // 권한 제거 - DB::table('model_has_permissions') + // 권한 제거 (soft delete) + DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $departmentId) ->where('permission_id', $permission->id) - ->delete(); + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->update([ + 'deleted_at' => now(), + 'deleted_by' => auth()->id(), + ]); $newValue = false; } else { - // 권한 부여 - DB::table('model_has_permissions')->insert([ - 'permission_id' => $permission->id, - 'model_type' => Department::class, - 'model_id' => $departmentId, - 'tenant_id' => $tenantId, - ]); + // 기존에 삭제된 레코드가 있으면 복원, 없으면 생성 + $existingRecord = DB::table('permission_overrides') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->first(); + + if ($existingRecord) { + // 기존 레코드 복원 + DB::table('permission_overrides') + ->where('id', $existingRecord->id) + ->update([ + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + // 새 레코드 생성 + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => Department::class, + 'model_id' => $departmentId, + 'permission_id' => $permission->id, + 'effect' => 1, // ALLOW + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } $newValue = true; } @@ -109,7 +164,7 @@ public function togglePermission(int $departmentId, int $menuId, string $permiss } /** - * 하위 부서에 권한 전파 + * 하위 부서에 권한 전파 (permission_overrides 테이블 사용) * * @param int $parentDepartmentId 부모 부서 ID * @param int $menuId 메뉴 ID @@ -121,6 +176,7 @@ public function togglePermission(int $departmentId, int $menuId, string $permiss protected function propagateToChildren(int $parentDepartmentId, int $menuId, string $permissionType, bool $value, ?int $tenantId = null, string $guardName = 'api'): void { $children = Department::where('parent_id', $parentDepartmentId)->get(); + $now = now(); foreach ($children as $child) { $permissionName = "menu:{$menuId}.{$permissionType}"; @@ -129,31 +185,73 @@ protected function propagateToChildren(int $parentDepartmentId, int $menuId, str ['tenant_id' => null, 'created_by' => auth()->id()] ); - // 현재 권한 상태 확인 - $exists = DB::table('model_has_permissions') + // 현재 권한 상태 확인 (유효한 ALLOW 오버라이드가 있는지) + $exists = DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $child->id) ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) ->exists(); if ($value) { // 권한 추가 if (! $exists) { - DB::table('model_has_permissions')->insert([ - 'permission_id' => $permission->id, - 'model_type' => Department::class, - 'model_id' => $child->id, - 'tenant_id' => $tenantId, - ]); - } - } else { - // 권한 제거 - if ($exists) { - DB::table('model_has_permissions') + // 기존에 삭제된 레코드가 있으면 복원, 없으면 생성 + $existingRecord = DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $child->id) ->where('permission_id', $permission->id) - ->delete(); + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->first(); + + if ($existingRecord) { + DB::table('permission_overrides') + ->where('id', $existingRecord->id) + ->update([ + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => Department::class, + 'model_id' => $child->id, + 'permission_id' => $permission->id, + 'effect' => 1, // ALLOW + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } + } + } else { + // 권한 제거 (soft delete) + if ($exists) { + DB::table('permission_overrides') + ->where('model_type', Department::class) + ->where('model_id', $child->id) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->update([ + 'deleted_at' => now(), + 'deleted_by' => auth()->id(), + ]); } } @@ -163,7 +261,7 @@ protected function propagateToChildren(int $parentDepartmentId, int $menuId, str } /** - * 모든 권한 허용 + * 모든 권한 허용 (permission_overrides 테이블 사용) * * @param int $departmentId 부서 ID * @param int|null $tenantId 테넌트 ID @@ -177,6 +275,8 @@ public function allowAllPermissions(int $departmentId, ?int $tenantId = null, st } $menus = $query->get(); + $now = now(); + foreach ($menus as $menu) { foreach ($this->permissionTypes as $type) { $permissionName = "menu:{$menu->id}.{$type}"; @@ -185,27 +285,64 @@ public function allowAllPermissions(int $departmentId, ?int $tenantId = null, st ['tenant_id' => null, 'created_by' => auth()->id()] ); - // 이미 존재하는지 확인 - $exists = DB::table('model_has_permissions') + // 이미 유효한 ALLOW 오버라이드가 있는지 확인 + $exists = DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $departmentId) ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) ->exists(); if (! $exists) { - DB::table('model_has_permissions')->insert([ - 'permission_id' => $permission->id, - 'model_type' => Department::class, - 'model_id' => $departmentId, - 'tenant_id' => $tenantId, - ]); + // 기존에 삭제된 레코드가 있으면 복원, 없으면 생성 + $existingRecord = DB::table('permission_overrides') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->first(); + + if ($existingRecord) { + DB::table('permission_overrides') + ->where('id', $existingRecord->id) + ->update([ + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => Department::class, + 'model_id' => $departmentId, + 'permission_id' => $permission->id, + 'effect' => 1, // ALLOW + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } } } } } /** - * 모든 권한 거부 + * 모든 권한 거부 (permission_overrides 테이블에서 삭제) * * @param int $departmentId 부서 ID * @param int|null $tenantId 테넌트 ID @@ -227,11 +364,18 @@ public function denyAllPermissions(int $departmentId, ?int $tenantId = null, str ->first(); if ($permission) { - DB::table('model_has_permissions') + // Soft delete all ALLOW overrides for this department + DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $departmentId) ->where('permission_id', $permission->id) - ->delete(); + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->whereNull('deleted_at') + ->update([ + 'deleted_at' => now(), + 'deleted_by' => auth()->id(), + ]); } } } @@ -256,6 +400,8 @@ public function resetToDefaultPermissions(int $departmentId, ?int $tenantId = nu } $menus = $query->get(); + $now = now(); + foreach ($menus as $menu) { $permissionName = "menu:{$menu->id}.view"; $permission = Permission::firstOrCreate( @@ -263,20 +409,57 @@ public function resetToDefaultPermissions(int $departmentId, ?int $tenantId = nu ['tenant_id' => null, 'created_by' => auth()->id()] ); - // 이미 존재하는지 확인 - $exists = DB::table('model_has_permissions') + // 이미 유효한 ALLOW 오버라이드가 있는지 확인 + $exists = DB::table('permission_overrides') ->where('model_type', Department::class) ->where('model_id', $departmentId) ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) ->exists(); if (! $exists) { - DB::table('model_has_permissions')->insert([ - 'permission_id' => $permission->id, - 'model_type' => Department::class, - 'model_id' => $departmentId, - 'tenant_id' => $tenantId, - ]); + // 기존에 삭제된 레코드가 있으면 복원, 없으면 생성 + $existingRecord = DB::table('permission_overrides') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->first(); + + if ($existingRecord) { + DB::table('permission_overrides') + ->where('id', $existingRecord->id) + ->update([ + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => Department::class, + 'model_id' => $departmentId, + 'permission_id' => $permission->id, + 'effect' => 1, // ALLOW + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } } } } @@ -334,7 +517,7 @@ private function flattenMenuTree(\Illuminate\Support\Collection $menus, ?int $pa } /** - * 특정 부서의 활성 메뉴 권한 확인 + * 특정 부서의 활성 메뉴 권한 확인 (permission_overrides 테이블 사용) * * @param int $departmentId 부서 ID * @param int $menuId 메뉴 ID @@ -345,13 +528,22 @@ private function flattenMenuTree(\Illuminate\Support\Collection $menus, ?int $pa public function hasPermission(int $departmentId, int $menuId, string $permissionType, string $guardName = 'api'): bool { $permissionName = "menu:{$menuId}.{$permissionType}"; + $now = now(); - return DB::table('model_has_permissions') - ->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id') - ->where('model_has_permissions.model_type', Department::class) - ->where('model_has_permissions.model_id', $departmentId) - ->where('permissions.name', $permissionName) - ->where('permissions.guard_name', $guardName) + return DB::table('permission_overrides as po') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->where('po.model_type', Department::class) + ->where('po.model_id', $departmentId) + ->where('po.effect', 1) + ->where('p.name', $permissionName) + ->where('p.guard_name', $guardName) + ->whereNull('po.deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }) ->exists(); } } diff --git a/app/Services/UserPermissionService.php b/app/Services/UserPermissionService.php new file mode 100644 index 00000000..57e450cb --- /dev/null +++ b/app/Services/UserPermissionService.php @@ -0,0 +1,710 @@ + 'allow'|'deny'|null, 'source' => 'role'|'department'|'personal'|null, 'personal' => 'allow'|'deny'|null] + */ + public function getUserPermissionMatrix(int $userId, ?int $tenantId = null, string $guardName = 'api'): array + { + $now = now(); + + // 1. 역할 권한 조회 (Spatie) + $rolePermissions = $this->getRolePermissions($userId, $guardName); + + // 2. 부서 권한 조회 (permission_overrides with Department) + $departmentPermissions = $this->getDepartmentPermissions($userId, $tenantId, $guardName); + + // 3. 개인 오버라이드 조회 (permission_overrides with User) + $personalOverrides = $this->getPersonalOverrides($userId, $tenantId, $guardName); + + // 4. 통합 매트릭스 생성 + $permissions = []; + + // 모든 메뉴 ID 수집 + $allMenuIds = array_unique(array_merge( + array_keys($rolePermissions), + array_keys($departmentPermissions), + array_keys($personalOverrides) + )); + + foreach ($allMenuIds as $menuId) { + if (! isset($permissions[$menuId])) { + $permissions[$menuId] = []; + } + + foreach ($this->permissionTypes as $type) { + $hasRole = isset($rolePermissions[$menuId][$type]) && $rolePermissions[$menuId][$type]; + $hasDept = isset($departmentPermissions[$menuId][$type]) && $departmentPermissions[$menuId][$type]; + $personal = $personalOverrides[$menuId][$type] ?? null; + + // 최종 권한 계산 (API AccessService 우선순위와 동일) + // 1) 개인 DENY → 거부 + // 2) 역할 권한 → 허용 + // 3) 부서 ALLOW → 허용 + // 4) 개인 ALLOW → 허용 + // 5) 기본 → 없음 + + $effective = null; + $source = null; + + if ($personal === 'deny') { + $effective = 'deny'; + $source = 'personal'; + } elseif ($hasRole) { + $effective = 'allow'; + $source = 'role'; + } elseif ($hasDept) { + $effective = 'allow'; + $source = 'department'; + } elseif ($personal === 'allow') { + $effective = 'allow'; + $source = 'personal'; + } + + $permissions[$menuId][$type] = [ + 'effective' => $effective, + 'source' => $source, + 'personal' => $personal, + ]; + } + } + + return $permissions; + } + + /** + * 역할 권한 조회 (Spatie model_has_roles + role_has_permissions) + */ + private function getRolePermissions(int $userId, string $guardName): array + { + $rolePermissions = DB::table('model_has_roles as mhr') + ->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id') + ->join('permissions as p', 'p.id', '=', 'rhp.permission_id') + ->where('mhr.model_type', User::class) + ->where('mhr.model_id', $userId) + ->where('p.guard_name', $guardName) + ->where('p.name', 'like', 'menu:%') + ->pluck('p.name') + ->toArray(); + + $result = []; + foreach ($rolePermissions as $permName) { + if (preg_match('/^menu:(\d+)\.(\w+)$/', $permName, $matches)) { + $menuId = (int) $matches[1]; + $type = $matches[2]; + + if (! isset($result[$menuId])) { + $result[$menuId] = []; + } + $result[$menuId][$type] = true; + } + } + + return $result; + } + + /** + * 부서 권한 조회 (permission_overrides with Department) + */ + private function getDepartmentPermissions(int $userId, ?int $tenantId, string $guardName): array + { + $now = now(); + + $query = DB::table('department_user as du') + ->join('permission_overrides as po', function ($j) use ($now) { + $j->on('po.model_id', '=', 'du.department_id') + ->where('po.model_type', 'App\\Models\\Tenants\\Department') + ->whereNull('po.deleted_at') + ->where('po.effect', 1) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }); + }) + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->whereNull('du.deleted_at') + ->where('du.user_id', $userId) + ->where('p.guard_name', $guardName) + ->where('p.name', 'like', 'menu:%'); + + if ($tenantId) { + $query->where('du.tenant_id', $tenantId) + ->where('po.tenant_id', $tenantId); + } + + $deptPermissions = $query->pluck('p.name')->toArray(); + + $result = []; + foreach ($deptPermissions as $permName) { + if (preg_match('/^menu:(\d+)\.(\w+)$/', $permName, $matches)) { + $menuId = (int) $matches[1]; + $type = $matches[2]; + + if (! isset($result[$menuId])) { + $result[$menuId] = []; + } + $result[$menuId][$type] = true; + } + } + + return $result; + } + + /** + * 개인 오버라이드 조회 (permission_overrides with User) + */ + private function getPersonalOverrides(int $userId, ?int $tenantId, string $guardName): array + { + $now = now(); + + $query = DB::table('permission_overrides as po') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->select('p.name', 'po.effect') + ->where('po.model_type', User::class) + ->where('po.model_id', $userId) + ->where('p.guard_name', $guardName) + ->where('p.name', 'like', 'menu:%') + ->whereNull('po.deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }); + + if ($tenantId) { + $query->where('po.tenant_id', $tenantId); + } + + $userPermissions = $query->get(); + + $result = []; + foreach ($userPermissions as $perm) { + if (preg_match('/^menu:(\d+)\.(\w+)$/', $perm->name, $matches)) { + $menuId = (int) $matches[1]; + $type = $matches[2]; + + if (! isset($result[$menuId])) { + $result[$menuId] = []; + } + $result[$menuId][$type] = $perm->effect == 1 ? 'allow' : 'deny'; + } + } + + return $result; + } + + /** + * 특정 메뉴의 특정 권한 토글 (스마트 토글) + * - 역할/부서 권한 있음 (개인 오버라이드 없음) → 개인 DENY 추가 + * - 개인 DENY → 제거 (역할/부서 권한으로 복원 또는 미설정) + * - 미설정 (권한 없음) → 개인 ALLOW 추가 + * - 개인 ALLOW → 개인 DENY로 변경 + * + * @param int $userId 사용자 ID + * @param int $menuId 메뉴 ID + * @param string $permissionType 권한 유형 + * @param int|null $tenantId 테넌트 ID + * @param string $guardName Guard 이름 (api 또는 web) + * @return string|null 토글 후 개인 오버라이드 상태 (null: 미설정, 'allow': 허용, 'deny': 거부) + */ + public function togglePermission(int $userId, int $menuId, string $permissionType, ?int $tenantId = null, string $guardName = 'api'): ?string + { + $permissionName = "menu:{$menuId}.{$permissionType}"; + + // 권한 생성 또는 조회 + $permission = Permission::firstOrCreate( + ['name' => $permissionName, 'guard_name' => $guardName], + ['tenant_id' => null, 'created_by' => auth()->id()] + ); + + $now = now(); + + // 현재 개인 오버라이드 조회 + $currentOverride = DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) + ->first(); + + // 역할/부서 권한 확인 + $hasRolePermission = $this->hasRolePermission($userId, $permissionName, $guardName); + $hasDeptPermission = $this->hasDeptPermission($userId, $permissionName, $tenantId, $guardName); + $hasInheritedPermission = $hasRolePermission || $hasDeptPermission; + + // 스마트 토글 로직 + if ($currentOverride) { + if ($currentOverride->effect == 0) { + // 개인 DENY → 제거 (역할/부서로 복원 또는 미설정) + DB::table('permission_overrides') + ->where('id', $currentOverride->id) + ->update([ + 'deleted_at' => now(), + 'deleted_by' => auth()->id(), + ]); + + return null; + } else { + // 개인 ALLOW → 개인 DENY + DB::table('permission_overrides') + ->where('id', $currentOverride->id) + ->update([ + 'effect' => 0, // DENY + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + + return 'deny'; + } + } else { + // 개인 오버라이드 없음 + if ($hasInheritedPermission) { + // 역할/부서 권한 있음 → 개인 DENY 추가 (오버라이드) + $this->createPersonalOverride($userId, $permission->id, $tenantId, 0); // DENY + + return 'deny'; + } else { + // 권한 없음 → 개인 ALLOW 추가 + $this->createPersonalOverride($userId, $permission->id, $tenantId, 1); // ALLOW + + return 'allow'; + } + } + } + + /** + * 역할 권한 존재 여부 확인 + */ + private function hasRolePermission(int $userId, string $permissionName, string $guardName): bool + { + return DB::table('model_has_roles as mhr') + ->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id') + ->join('permissions as p', 'p.id', '=', 'rhp.permission_id') + ->where('mhr.model_type', User::class) + ->where('mhr.model_id', $userId) + ->where('p.guard_name', $guardName) + ->where('p.name', $permissionName) + ->exists(); + } + + /** + * 부서 권한 존재 여부 확인 + */ + private function hasDeptPermission(int $userId, string $permissionName, ?int $tenantId, string $guardName): bool + { + $now = now(); + + $query = DB::table('department_user as du') + ->join('permission_overrides as po', function ($j) use ($now) { + $j->on('po.model_id', '=', 'du.department_id') + ->where('po.model_type', 'App\\Models\\Tenants\\Department') + ->whereNull('po.deleted_at') + ->where('po.effect', 1) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }); + }) + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->whereNull('du.deleted_at') + ->where('du.user_id', $userId) + ->where('p.guard_name', $guardName) + ->where('p.name', $permissionName); + + if ($tenantId) { + $query->where('du.tenant_id', $tenantId) + ->where('po.tenant_id', $tenantId); + } + + return $query->exists(); + } + + /** + * 개인 오버라이드 생성 (삭제된 레코드 복원 또는 새로 생성) + */ + private function createPersonalOverride(int $userId, int $permissionId, ?int $tenantId, int $effect): void + { + $deletedRecord = DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permissionId) + ->where('tenant_id', $tenantId) + ->whereNotNull('deleted_at') + ->first(); + + if ($deletedRecord) { + DB::table('permission_overrides') + ->where('id', $deletedRecord->id) + ->update([ + 'effect' => $effect, + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => User::class, + 'model_id' => $userId, + 'permission_id' => $permissionId, + 'effect' => $effect, + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } + } + + /** + * 모든 권한 허용 (permission_overrides 테이블 사용) + * 모든 메뉴에 대해 ALLOW 상태로 설정 (기존 DENY 포함하여 모두 ALLOW로 변경) + * + * @param int $userId 사용자 ID + * @param int|null $tenantId 테넌트 ID + * @param string $guardName Guard 이름 (api 또는 web) + */ + public function allowAllPermissions(int $userId, ?int $tenantId = null, string $guardName = 'api'): void + { + $query = Menu::where('is_active', 1); + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + $menus = $query->get(); + + $now = now(); + + foreach ($menus as $menu) { + foreach ($this->permissionTypes as $type) { + $permissionName = "menu:{$menu->id}.{$type}"; + $permission = Permission::firstOrCreate( + ['name' => $permissionName, 'guard_name' => $guardName], + ['tenant_id' => null, 'created_by' => auth()->id()] + ); + + // 현재 유효한 오버라이드 확인 (ALLOW 또는 DENY) + $existingOverride = DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) + ->first(); + + if ($existingOverride) { + // 기존 오버라이드가 있으면 ALLOW로 변경 + if ($existingOverride->effect != 1) { + DB::table('permission_overrides') + ->where('id', $existingOverride->id) + ->update([ + 'effect' => 1, // ALLOW + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } + } else { + // 삭제된 레코드가 있으면 복원, 없으면 생성 + $deletedRecord = DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->whereNotNull('deleted_at') + ->first(); + + if ($deletedRecord) { + DB::table('permission_overrides') + ->where('id', $deletedRecord->id) + ->update([ + 'effect' => 1, // ALLOW + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => User::class, + 'model_id' => $userId, + 'permission_id' => $permission->id, + 'effect' => 1, // ALLOW + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } + } + } + } + } + + /** + * 모든 권한 초기화 (모두 미설정으로) + * 모든 오버라이드 레코드를 soft delete하여 미설정 상태로 초기화 + * + * @param int $userId 사용자 ID + * @param int|null $tenantId 테넌트 ID + * @param string $guardName Guard 이름 (api 또는 web) + */ + public function denyAllPermissions(int $userId, ?int $tenantId = null, string $guardName = 'api'): void + { + $query = Menu::where('is_active', 1); + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + $menus = $query->get(); + + foreach ($menus as $menu) { + foreach ($this->permissionTypes as $type) { + $permissionName = "menu:{$menu->id}.{$type}"; + $permission = Permission::where('name', $permissionName) + ->where('guard_name', $guardName) + ->first(); + + if ($permission) { + // Soft delete all overrides (ALLOW or DENY) for this user + DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->update([ + 'deleted_at' => now(), + 'deleted_by' => auth()->id(), + ]); + } + } + } + } + + /** + * 기본 권한으로 초기화 (view만 허용) + * + * @param int $userId 사용자 ID + * @param int|null $tenantId 테넌트 ID + * @param string $guardName Guard 이름 (api 또는 web) + */ + public function resetToDefaultPermissions(int $userId, ?int $tenantId = null, string $guardName = 'api'): void + { + // 1. 먼저 모든 권한 제거 + $this->denyAllPermissions($userId, $tenantId, $guardName); + + // 2. view 권한만 허용 + $query = Menu::where('is_active', 1); + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + $menus = $query->get(); + + $now = now(); + + foreach ($menus as $menu) { + $permissionName = "menu:{$menu->id}.view"; + $permission = Permission::firstOrCreate( + ['name' => $permissionName, 'guard_name' => $guardName], + ['tenant_id' => null, 'created_by' => auth()->id()] + ); + + // 이미 유효한 ALLOW 오버라이드가 있는지 확인 + $exists = DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->whereNull('deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('effective_from')->orWhere('effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('effective_to')->orWhere('effective_to', '>=', $now); + }) + ->exists(); + + if (! $exists) { + // 기존에 삭제된 레코드가 있으면 복원, 없으면 생성 + $existingRecord = DB::table('permission_overrides') + ->where('model_type', User::class) + ->where('model_id', $userId) + ->where('permission_id', $permission->id) + ->where('tenant_id', $tenantId) + ->where('effect', 1) + ->first(); + + if ($existingRecord) { + DB::table('permission_overrides') + ->where('id', $existingRecord->id) + ->update([ + 'deleted_at' => null, + 'deleted_by' => null, + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => User::class, + 'model_id' => $userId, + 'permission_id' => $permission->id, + 'effect' => 1, // ALLOW + 'reason' => null, + 'effective_from' => null, + 'effective_to' => null, + 'created_at' => now(), + 'created_by' => auth()->id(), + 'updated_at' => now(), + 'updated_by' => auth()->id(), + ]); + } + } + } + } + + /** + * 메뉴 트리 조회 (권한 매트릭스 표시용) + * + * @param int|null $tenantId 테넌트 ID + * @return \Illuminate\Support\Collection 메뉴 트리 + */ + public function getMenuTree(?int $tenantId = null): \Illuminate\Support\Collection + { + $query = Menu::with('parent') + ->where('is_active', 1); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $allMenus = $query->orderBy('sort_order', 'asc') + ->orderBy('id', 'asc') + ->get(); + + // depth 계산하여 플랫한 구조로 변환 + return $this->flattenMenuTree($allMenus); + } + + /** + * 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) + * + * @param \Illuminate\Support\Collection $menus 메뉴 컬렉션 + * @param int|null $parentId 부모 메뉴 ID + * @param int $depth 현재 깊이 + */ + private function flattenMenuTree(\Illuminate\Support\Collection $menus, ?int $parentId = null, int $depth = 0): \Illuminate\Support\Collection + { + $result = collect(); + + $filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order'); + + foreach ($filteredMenus as $menu) { + $menu->depth = $depth; + + // 자식 메뉴 존재 여부 확인 + $menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0; + + $result->push($menu); + + // 자식 메뉴 재귀적으로 추가 + $children = $this->flattenMenuTree($menus, $menu->id, $depth + 1); + $result = $result->merge($children); + } + + return $result; + } + + /** + * 특정 사용자의 활성 메뉴 권한 확인 (permission_overrides 테이블 사용) + * + * @param int $userId 사용자 ID + * @param int $menuId 메뉴 ID + * @param string $permissionType 권한 유형 + * @param string $guardName Guard 이름 (api 또는 web) + * @return bool 권한 존재 여부 + */ + public function hasPermission(int $userId, int $menuId, string $permissionType, string $guardName = 'api'): bool + { + $permissionName = "menu:{$menuId}.{$permissionType}"; + $now = now(); + + return DB::table('permission_overrides as po') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->where('po.model_type', User::class) + ->where('po.model_id', $userId) + ->where('po.effect', 1) + ->where('p.name', $permissionName) + ->where('p.guard_name', $guardName) + ->whereNull('po.deleted_at') + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }) + ->exists(); + } + + /** + * 테넌트별 사용자 목록 조회 + * + * @param int $tenantId 테넌트 ID + * @return \Illuminate\Support\Collection 사용자 목록 + */ + public function getUsersByTenant(int $tenantId): \Illuminate\Support\Collection + { + return User::whereHas('tenants', function ($query) use ($tenantId) { + $query->where('tenants.id', $tenantId) + ->where('user_tenants.is_active', true); + }) + ->where('is_active', true) + ->orderBy('name') + ->get(); + } +} diff --git a/docs/INDEX.md b/docs/INDEX.md index 6ad40606..402dd3fd 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -77,6 +77,7 @@ ### 프로젝트 문서 **MNG 프로젝트:** - **[🚨 MNG_CRITICAL_RULES.md](./MNG_CRITICAL_RULES.md)** - 절대 위반 금지 규칙 (필독!) +- **[📋 TABLE_LAYOUT_STANDARD.md](./TABLE_LAYOUT_STANDARD.md)** - 테이블 페이지 레이아웃 표준 (권한 관리 페이지 기반) - **[CURRENT_WORKS.md](../CURRENT_WORKS.md)** - 현재 작업 진행 상황 - **[TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** - 트러블슈팅 가이드 - **[MIGRATION_PLAN.md](./MIGRATION_PLAN.md)** - Admin → MNG 마이그레이션 계획 (Phase 4) diff --git a/docs/TABLE_LAYOUT_STANDARD.md b/docs/TABLE_LAYOUT_STANDARD.md new file mode 100644 index 00000000..f4665e8d --- /dev/null +++ b/docs/TABLE_LAYOUT_STANDARD.md @@ -0,0 +1,669 @@ +# MNG 테이블 레이아웃 표준 + +> **기준 페이지**: `/permissions` (권한 관리) +> **작성일**: 2025-11-25 +> **목적**: mng 프로젝트의 모든 테이블 페이지에서 일관된 레이아웃과 UX를 제공 + +--- + +## 📋 목차 + +1. [페이지 구조](#1-페이지-구조) +2. [페이지 헤더](#2-페이지-헤더) +3. [필터 영역](#3-필터-영역) +4. [테이블 구조](#4-테이블-구조) +5. [페이지네이션](#5-페이지네이션) +6. [기술 스택](#6-기술-스택) +7. [체크리스트](#7-체크리스트) + +--- + +## 1. 페이지 구조 + +### 1.1 전체 레이아웃 순서 + +```blade +@extends('layouts.app') + +@section('content') + +
+ +
+ + +
+ +
+ + +
+ + +
+@endsection +``` + +### 1.2 파일 구조 + +``` +resources/views/{resource}/ +├── index.blade.php # 메인 페이지 (레이아웃만) +├── create.blade.php # 생성 폼 +├── edit.blade.php # 수정 폼 +└── partials/ + └── table.blade.php # 테이블 + 페이지네이션 +``` + +--- + +## 2. 페이지 헤더 + +### 2.1 기본 구조 + +```blade +
+

{페이지 제목}

+ + + {액션 버튼 레이블} + +
+``` + +### 2.2 스타일 규칙 + +- **제목**: `text-2xl font-bold text-gray-800` +- **액션 버튼**: `bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition` +- **간격**: `mb-6` (하단 여백) + +--- + +## 3. 필터 영역 + +### 3.1 기본 구조 + +```blade +
+
+ +
+ +
+ + +
+ +
+ + + +
+
+``` + +### 3.2 스타일 규칙 + +- **컨테이너**: `bg-white rounded-lg shadow-sm p-4 mb-6` +- **폼**: `flex gap-4` (가로 배치, 간격 4) +- **검색 입력**: `flex-1` (가변 폭) +- **드롭다운**: `w-48` (고정 폭 192px) +- **버튼**: `bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition` + +### 3.3 JavaScript 이벤트 + +```javascript +document.getElementById('filterForm').addEventListener('submit', function(e) { + e.preventDefault(); + htmx.trigger('#{resource}-table', 'filterSubmit'); +}); +``` + +--- + +## 4. 테이블 구조 + +### 4.1 HTMX 컨테이너 + +```blade +
+ +
+
+
+
+``` + +### 4.2 테이블 Partial (`partials/table.blade.php`) + +```blade +
+ + + + + + + + + + @forelse($items as $item) + + + + + + @empty + + + + @endforelse + +
+ {컬럼명} + + 액션 +
+ {{ $item->속성 }} + + + 수정 + + +
+ 등록된 {항목명}이(가) 없습니다. +
+
+ + +@include('partials.pagination', [ + 'paginator' => $items, + 'target' => '#{resource}-table', + 'includeForm' => '#filterForm' +]) +``` + +### 4.3 스타일 규칙 + +#### 테이블 헤더 +- **배경**: `bg-gray-50` +- **텍스트**: `text-sm font-semibold text-gray-700 uppercase tracking-wider` +- **정렬**: `text-left` (일반), `text-right` (액션) +- **패딩**: `px-4 py-2` + +#### 테이블 본문 +- **행 구분**: `divide-y divide-gray-200` +- **셀 패딩**: `px-4 py-3` +- **텍스트**: `text-sm text-gray-900` (일반), `text-gray-500` (보조) +- **공백 처리**: `whitespace-nowrap` (줄바꿈 방지) + +#### 액션 버튼 +- **수정**: `text-blue-600 hover:text-blue-900 mr-3` +- **삭제**: `text-red-600 hover:text-red-900` + +### 4.4 배지 스타일 (선택사항) + +#### Inline 스타일 배지 + +```blade + + {배지 텍스트} + +``` + +#### 배지 색상 시스템 + +| 용도 | 배경색 (RGB) | 텍스트색 (RGB) | 사용 예시 | +|------|-------------|---------------|----------| +| **Primary (파란색)** | `rgb(219 234 254)` | `rgb(30 64 175)` | Guard, 기본 태그 | +| **Success (초록색)** | `rgb(220 252 231)` | `rgb(21 128 61)` | 역할, 활성 상태 | +| **Warning (노란색)** | `rgb(254 249 195)` | `rgb(133 77 14)` | 부서, 경고 | +| **Danger (빨간색)** | `rgb(254 202 202)` | `rgb(153 27 27)` | 삭제 권한 | +| **Gray (회색)** | `rgb(243 244 246)` | `rgb(31 41 55)` | 메뉴 태그, 중립 | +| **Orange (주황색)** | `rgb(254 215 170)` | `rgb(154 52 18)` | 수정 권한 | +| **Purple (보라색)** | `rgb(233 213 255)` | `rgb(107 33 168)` | 승인 권한 | +| **Cyan (청록색)** | `rgb(207 250 254)` | `rgb(14 116 144)` | 내보내기 권한 | + +#### Tailwind 클래스 배지 (대안) + +```blade + + {배지 텍스트} + +``` + +### 4.5 Empty State + +```blade +@empty + + + 등록된 {항목명}이(가) 없습니다. + + +@endforelse +``` + +--- + +## 5. 페이지네이션 + +### 5.1 Include 방식 + +```blade +@include('partials.pagination', [ + 'paginator' => $items, + 'target' => '#{resource}-table', + 'includeForm' => '#filterForm' +]) +``` + +### 5.2 페이지네이션 기능 + +#### 데스크톱 (>=640px) +- **전체 개수 표시**: "전체 N개 중 X ~ Y" +- **페이지당 항목 수 선택**: 10/20/30/50/100/200/500개씩 +- **네비게이션 버튼**: + - 처음 (첫 페이지로) + - 이전 (이전 페이지로) + - 페이지 번호 (최대 10개 표시) + - 다음 (다음 페이지로) + - 끝 (마지막 페이지로) + +#### 모바일 (<640px) +- 이전/다음 버튼만 표시 +- 간소화된 네비게이션 + +### 5.3 JavaScript 핸들러 + +```javascript +// 페이지 변경 +function handlePageChange(page) { + const form = document.getElementById('filterForm'); + const formData = new FormData(form); + formData.append('page', page); + + const params = new URLSearchParams(formData).toString(); + htmx.ajax('GET', `/api/admin/{resource}?${params}`, { + target: '#{resource}-table', + swap: 'innerHTML' + }); +} + +// 페이지당 항목 수 변경 +function handlePerPageChange(perPage) { + const form = document.getElementById('filterForm'); + const formData = new FormData(form); + formData.append('per_page', perPage); + formData.append('page', 1); // 첫 페이지로 리셋 + + const params = new URLSearchParams(formData).toString(); + htmx.ajax('GET', `/api/admin/{resource}?${params}`, { + target: '#{resource}-table', + swap: 'innerHTML' + }); +} +``` + +### 5.4 스타일 규칙 + +- **컨테이너**: `bg-white px-4 py-3 border-t border-gray-200 sm:px-6` +- **전체 개수**: `text-sm text-gray-700`, 숫자는 `font-medium` +- **페이지당 항목 선택**: `px-3 py-1 border border-gray-300 rounded-lg text-sm` +- **버튼 (활성)**: `bg-white text-gray-700 hover:bg-gray-50` +- **버튼 (비활성)**: `bg-gray-100 text-gray-400 cursor-not-allowed` +- **현재 페이지**: `bg-blue-50 text-blue-600` + +--- + +## 6. 기술 스택 + +### 6.1 필수 라이브러리 + +```html + + + + +``` + +### 6.2 API 컨트롤러 + +```php +namespace App\Http\Controllers\Api\Admin; + +class {Resource}Controller extends Controller +{ + public function __construct( + private {Resource}Service $service + ) {} + + public function index(Request $request) + { + $items = $this->service->get{Resources}( + $request->all(), + $request->input('per_page', 20) + ); + + // HTMX 요청 시 부분 HTML 반환 + if ($request->header('HX-Request')) { + return view('{resource}.partials.table', compact('items')); + } + + // 일반 요청 시 JSON 반환 + return response()->json([ + 'success' => true, + 'data' => $items->items(), + 'meta' => [ + 'current_page' => $items->currentPage(), + 'total' => $items->total(), + 'per_page' => $items->perPage(), + 'last_page' => $items->lastPage(), + ], + ]); + } +} +``` + +### 6.3 라우트 + +```php +// web.php (화면) +Route::get('/{resource}', [{Resource}Controller::class, 'index']) + ->name('{resource}.index'); + +// api.php (데이터) +Route::prefix('api/admin')->group(function () { + Route::get('/{resource}', [Api\Admin\{Resource}Controller::class, 'index']); + Route::post('/{resource}', [Api\Admin\{Resource}Controller::class, 'store']); + Route::get('/{resource}/{id}', [Api\Admin\{Resource}Controller::class, 'show']); + Route::put('/{resource}/{id}', [Api\Admin\{Resource}Controller::class, 'update']); + Route::delete('/{resource}/{id}', [Api\Admin\{Resource}Controller::class, 'destroy']); +}); +``` + +--- + +## 7. 체크리스트 + +### 7.1 페이지 생성 체크리스트 + +```markdown +## 새 테이블 페이지 생성 체크리스트 + +### 파일 구조 +- [ ] `resources/views/{resource}/index.blade.php` 생성 +- [ ] `resources/views/{resource}/partials/table.blade.php` 생성 +- [ ] `app/Http/Controllers/{Resource}Controller.php` 생성 +- [ ] `app/Http/Controllers/Api/Admin/{Resource}Controller.php` 생성 +- [ ] `app/Services/{Resource}Service.php` 생성 + +### 페이지 헤더 +- [ ] 제목 (`text-2xl font-bold text-gray-800`) +- [ ] 액션 버튼 (`bg-blue-600 hover:bg-blue-700`) +- [ ] 하단 여백 (`mb-6`) + +### 필터 영역 (선택사항) +- [ ] 검색 입력 (`flex-1`) +- [ ] 드롭다운 필터 (`w-48`) +- [ ] 검색 버튼 (`bg-gray-600`) +- [ ] JavaScript 이벤트 핸들러 + +### 테이블 +- [ ] HTMX 컨테이너 (`hx-get`, `hx-trigger`, `hx-include`) +- [ ] 로딩 스피너 +- [ ] 테이블 헤더 (`bg-gray-50`) +- [ ] 테이블 본문 (`divide-y divide-gray-200`) +- [ ] Empty State +- [ ] 액션 버튼 (수정, 삭제) + +### 페이지네이션 +- [ ] `@include('partials.pagination')` 추가 +- [ ] `handlePageChange()` 함수 구현 +- [ ] `handlePerPageChange()` 함수 구현 + +### API +- [ ] `index()` 메서드 (HTMX + JSON 분기) +- [ ] Service 계층 (비즈니스 로직) +- [ ] FormRequest (검증) +- [ ] 라우트 등록 + +### 테스트 +- [ ] 필터 검색 동작 확인 +- [ ] 페이지네이션 동작 확인 +- [ ] 액션 버튼 동작 확인 +- [ ] 반응형 레이아웃 확인 (모바일/데스크톱) +``` + +### 7.2 스타일 일관성 체크 + +```markdown +## 스타일 일관성 체크리스트 + +### 색상 +- [ ] Primary 버튼: `bg-blue-600 hover:bg-blue-700` +- [ ] Secondary 버튼: `bg-gray-600 hover:bg-gray-700` +- [ ] 텍스트: `text-gray-800` (제목), `text-gray-700` (본문), `text-gray-500` (보조) + +### 간격 +- [ ] 페이지 헤더 하단: `mb-6` +- [ ] 필터 영역 하단: `mb-6` +- [ ] 필터 요소 간격: `gap-4` +- [ ] 테이블 셀 패딩: `px-4 py-3` (본문), `px-4 py-2` (헤더) + +### 둥근 모서리 +- [ ] 버튼: `rounded-lg` +- [ ] 입력 필드: `rounded-lg` +- [ ] 배지: `rounded` (0.375rem) +- [ ] 컨테이너: `rounded-lg` + +### 그림자 +- [ ] 컨테이너: `shadow-sm` +- [ ] 페이지네이션: `shadow-sm` +``` + +--- + +## 8. 참고 사항 + +### 8.1 권한 관리 페이지 특수 기능 + +권한 관리 페이지는 다음과 같은 특수 기능을 포함합니다: + +1. **권한명 파싱**: `menu:{menu_id}.{permission_type}` 형식 파싱 +2. **권한 타입 배지**: V(조회), C(생성), U(수정), D(삭제), A(승인), E(내보내기), M(관리) +3. **메뉴 태그**: 회색 배지로 메뉴 ID 표시 +4. **역할/부서 배지**: 여러 개 배지를 가로 나열 (`flex flex-nowrap gap-1`) + +이러한 특수 기능은 다른 페이지에서 필요에 따라 적용하거나 생략할 수 있습니다. + +### 8.2 성능 최적화 + +- **Eager Loading**: 관계 데이터를 미리 로드하여 N+1 쿼리 방지 + ```php + $items = Model::with(['relation1', 'relation2'])->paginate(20); + ``` +- **페이지네이션**: 기본값 20개, 최대 500개까지 지원 +- **HTMX**: 부분 HTML만 교체하여 빠른 반응성 제공 + +### 8.3 접근성 + +- **시맨틱 HTML**: ``, ``, `` 사용 +- **버튼 레이블**: 명확한 액션 설명 +- **키보드 네비게이션**: 버튼과 링크에 포커스 가능 + +--- + +## 9. 예제 코드 + +### 9.1 최소 구현 예제 + +#### `resources/views/products/index.blade.php` + +```blade +@extends('layouts.app') + +@section('title', '제품 관리') + +@section('content') + +
+

제품 관리

+ + + 새 제품 + +
+ + +
+
+
+ +
+ + +
+ + +
+
+
+
+
+@endsection + +@push('scripts') + + +@endpush +``` + +#### `resources/views/products/partials/table.blade.php` + +```blade +
+
+ + + + + + + + + + @forelse($products as $product) + + + + + + + @empty + + + + @endforelse + +
ID제품명생성일액션
+ {{ $product->id }} + + {{ $product->name }} + + {{ $product->created_at?->format('Y-m-d H:i') ?? '-' }} + + + 수정 + + +
+ 등록된 제품이 없습니다. +
+ + +@include('partials.pagination', [ + 'paginator' => $products, + 'target' => '#product-table', + 'includeForm' => '#filterForm' +]) +``` + +--- + +## 10. 문서 이력 + +| 버전 | 날짜 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 1.0 | 2025-11-25 | Claude | 초안 작성 (권한 관리 페이지 기반) | + +--- + +## 11. 문의 + +이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요. \ No newline at end of file diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 5401aa52..d2b2147b 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -122,8 +122,8 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
  • - diff --git a/resources/views/tenants/partials/table.blade.php b/resources/views/tenants/partials/table.blade.php index 3f25e1f7..2ae7d674 100644 --- a/resources/views/tenants/partials/table.blade.php +++ b/resources/views/tenants/partials/table.blade.php @@ -3,16 +3,16 @@
    ID - 회사명 - 코드 - 상태 - 이메일 - 전화번호 - 사용자 - 부서 - 메뉴 - 역할 - 생성일 + 회사명 + 코드 + 상태 + 이메일 + 전화번호 + 사용자 + 부서 + 메뉴 + 역할 + 생성일 액션
    @@ -22,16 +22,16 @@ {{ $tenant->id }} - +
    {{ $tenant->company_name }}
    @if($tenant->ceo_name)
    대표: {{ $tenant->ceo_name }}
    @endif - + {{ $tenant->code }} - + + {{ $tenant->email ?? '-' }} - + {{ $tenant->phone ?? '-' }} - + {{ $tenant->users_count ?? 0 }} - + {{ $tenant->departments_count ?? 0 }} - + {{ $tenant->menus_count ?? 0 }} - + {{ $tenant->roles_count ?? 0 }} - + {{ $tenant->created_at?->format('Y-m-d') ?? '-' }} diff --git a/resources/views/user-permissions/index.blade.php b/resources/views/user-permissions/index.blade.php new file mode 100644 index 00000000..6bdbb7f6 --- /dev/null +++ b/resources/views/user-permissions/index.blade.php @@ -0,0 +1,172 @@ +@extends('layouts.app') + +@section('title', '개인 권한 관리') + +@section('content') + +
    +

    개인 권한 관리

    +
    + + +
    +
    + @if($requireTenant) + {{-- 전체 테넌트 선택 시: 테넌트 선택 안내 --}} +
    +
    ⚠️
    +

    테넌트를 선택해주세요

    +

    개인 권한을 관리하려면 상단 헤더에서 특정 테넌트를 선택해야 합니다.

    +
    + @else + {{-- 특정 테넌트 선택 시: 사용자 목록 표시 --}} + @if($users->isEmpty()) +
    +
    👤
    +

    사용자가 없습니다

    +

    선택한 테넌트에 등록된 사용자가 없습니다.

    +
    + @else +
    + 사용자 선택: + @foreach($users as $user) + + @endforeach +
    + @endif + @endif +
    +
    + + + + + +
    + @include('user-permissions.partials.empty-state') +
    + + +@endsection diff --git a/resources/views/user-permissions/partials/empty-state.blade.php b/resources/views/user-permissions/partials/empty-state.blade.php new file mode 100644 index 00000000..77378da5 --- /dev/null +++ b/resources/views/user-permissions/partials/empty-state.blade.php @@ -0,0 +1,17 @@ +
    +
    +
    +
    + + + +
    +
    +

    + 사용자를 선택해주세요 +

    +

    + 상단에서 사용자를 선택하면 해당 사용자의 개인 권한을 설정할 수 있습니다. +

    +
    +
    diff --git a/resources/views/user-permissions/partials/permission-matrix.blade.php b/resources/views/user-permissions/partials/permission-matrix.blade.php new file mode 100644 index 00000000..f6774e9f --- /dev/null +++ b/resources/views/user-permissions/partials/permission-matrix.blade.php @@ -0,0 +1,163 @@ +
    + + + + + + + + + + + + + + + + + + @php + $permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']; + @endphp + @forelse($menus as $index => $menu) + + + + + + @foreach($permissionTypes as $type) + @php + // 권한 데이터 추출 + $permData = $permissions[$menu->id][$type] ?? ['effective' => null, 'source' => null, 'personal' => null]; + $effective = $permData['effective'] ?? null; + $source = $permData['source'] ?? null; + $personal = $permData['personal'] ?? null; + + // 스타일 결정 + // - 개인 DENY: 빨간색 (최우선) + // - 개인 ALLOW: 녹색 + // - 역할 권한: 보라색 + // - 부서 권한: 파란색 + // - 미설정: 회색 + if ($personal === 'deny') { + $bgClass = 'bg-red-100 text-red-600 hover:bg-red-200 focus:ring-red-500'; + $icon = 'deny'; + $title = '개인 거부 (클릭: 미설정으로 변경)'; + } elseif ($personal === 'allow') { + $bgClass = 'bg-green-100 text-green-600 hover:bg-green-200 focus:ring-green-500'; + $icon = 'allow'; + $title = '개인 허용 (클릭: 거부로 변경)'; + } elseif ($source === 'role') { + $bgClass = 'bg-purple-100 text-purple-600 hover:bg-purple-200 focus:ring-purple-500'; + $icon = 'allow'; + $title = '역할 권한 (클릭: 개인 거부로 오버라이드)'; + } elseif ($source === 'department') { + $bgClass = 'bg-blue-100 text-blue-600 hover:bg-blue-200 focus:ring-blue-500'; + $icon = 'allow'; + $title = '부서 권한 (클릭: 개인 거부로 오버라이드)'; + } else { + $bgClass = 'bg-gray-100 text-gray-400 hover:bg-gray-200 focus:ring-gray-400'; + $icon = 'none'; + $title = '미설정 (클릭: 개인 허용으로 변경)'; + } + @endphp + + @endforeach + + @empty + + + + @endforelse + +
    순번메뉴명URL순서조회생성수정삭제승인내보내기관리
    + {{ $index + 1 }} + +
    + @if(($menu->depth ?? 0) > 0) + + @endif + {{ $menu->name }} +
    +
    + + {{ $menu->url }} + + + {{ $menu->sort_order }} + + +
    + 활성화된 메뉴가 없습니다. +
    +
    + +{{-- 범례 --}} +
    + 범례: +
    + + + + + + 미설정 +
    +
    + + + + + + 역할 +
    +
    + + + + + + 부서 +
    +
    + + + + + + 개인 허용 +
    +
    + + + + + + 개인 거부 +
    +
    diff --git a/routes/web.php b/routes/web.php index f8513722..8802bdeb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -81,6 +81,9 @@ // 부서 권한 관리 (Blade 화면만) Route::get('/department-permissions', [\App\Http\Controllers\DepartmentPermissionController::class, 'index'])->name('department-permissions.index'); + // 개인 권한 관리 (Blade 화면만) + Route::get('/user-permissions', [\App\Http\Controllers\UserPermissionController::class, 'index'])->name('user-permissions.index'); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');