diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 7b76c8d4..56033d48 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -863,4 +863,83 @@ ### Git 커밋: **최종 업데이트**: 2025-11-25 13:00 **현재 Phase**: Phase 4-4-2 완료, 브라우저 테스트 대기 중 -**다음 작업**: 브라우저 동작 확인 및 오류 수정 \ No newline at end of file +**다음 작업**: 브라우저 동작 확인 및 오류 수정 +## 2025-11-25 (월) - 부서 권한 관리 구현 시작 + +### 주요 작업 +- 역할 권한 관리와 유사한 부서 권한 관리 구현 +- admin 패널의 부서 권한 관리를 MNG로 이식 (Livewire → HTMX) + +### 문서 확인 완료: +- ✅ CURRENT_WORKS.md 백업 완료 +- ✅ MNG_CRITICAL_RULES.md 확인 (금지사항 숙지) +- ✅ Department 마이그레이션 확인 (parent_id 계층 구조) +- ✅ Permission 통합 마이그레이션 확인 + +### DB 스키마 분석: +**중요 발견:** +- ❌ `department_permissions` 테이블 드롭됨 (2025_08_21 마이그레이션) +- ✅ `permission_overrides` 테이블 신설 (Spatie 표준화) +- ✅ Department 모델은 HasRoles trait 사용 (Spatie) +- ✅ permissionOverrides morphMany 관계 사용 + +**권한 관리 구조:** +- Spatie 표준: `model_has_permissions` (역할/부서 권한) +- 개별 오버라이드: `permission_overrides` (ALLOW/DENY) +- 부서는 model_type='App\Models\Tenants\Department' 사용 + +### 다음 작업: +- [ ] departments 테이블 전체 스키마 확인 +- [ ] permission_overrides 테이블 구조 상세 파악 +- [ ] 부서 권한 관리 UI/UX 설계 +- [ ] DepartmentPermissionService 설계 +- [ ] 권한 매트릭스 구현 + +--- + + +### 추가된 파일: +- `app/Services/DepartmentPermissionService.php` - 부서 권한 비즈니스 로직 +- `app/Http/Controllers/DepartmentPermissionController.php` - Blade 화면 컨트롤러 +- `app/Http/Controllers/Api/Admin/DepartmentPermissionController.php` - HTMX API 컨트롤러 +- `resources/views/department-permissions/index.blade.php` - 메인 페이지 +- `resources/views/department-permissions/partials/empty-state.blade.php` - 빈 상태 화면 +- `resources/views/department-permissions/partials/permission-matrix.blade.php` - 권한 매트릭스 테이블 + +### 수정된 파일: +- `routes/web.php` - 부서 권한 관리 라우트 추가 +- `routes/api.php` - 부서 권한 관리 API 라우트 추가 +- `resources/views/partials/sidebar.blade.php` - 부서 권한 관리 메뉴 활성화 + +### 기술 구조: +**권한 관리 방식:** +- Department 모델이 HasRoles trait 사용 (Spatie) +- `model_has_permissions` 테이블 사용 (model_type = 'App\\Models\\Tenants\\Department') +- 역할 권한 관리와 동일한 패턴 적용 +- 하위 부서 권한 자동 전파 (재귀 처리) + +**주요 메서드 (DepartmentPermissionService):** +- `getDepartmentPermissionMatrix()` - 부서별 권한 매트릭스 조회 +- `togglePermission()` - 특정 메뉴 권한 토글 +- `propagateToChildren()` - 하위 부서 권한 전파 +- `allowAllPermissions()` - 모든 권한 허용 +- `denyAllPermissions()` - 모든 권한 거부 +- `getMenuTree()` - 메뉴 트리 조회 (depth 계산) + +**UI 특징:** +- 부서 선택: 버튼 방식 (역할 권한 관리와 동일) +- 권한 매트릭스: 체크박스로 권한 토글 +- 액션 버튼: 전체 허용/거부/초기화 +- HTMX 실시간 업데이트 + +**테넌트 필터링:** +- 세션의 `selected_tenant_id` 기준으로 필터링 +- 테넌트별 부서와 권한만 표시 + +### 다음 작업: +- [ ] 브라우저 테스트 +- [ ] 권한 전파 동작 확인 +- [ ] CSS 빌드 (필요 시) +- [ ] 오류 수정 및 개선 + +--- diff --git a/app/Http/Controllers/Api/Admin/DepartmentPermissionController.php b/app/Http/Controllers/Api/Admin/DepartmentPermissionController.php new file mode 100644 index 00000000..c96fed03 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DepartmentPermissionController.php @@ -0,0 +1,112 @@ +departmentPermissionService = $departmentPermissionService; + } + + /** + * 권한 매트릭스 조회 (부서 변경 시 호출) + */ + public function getMatrix(Request $request) + { + $departmentId = $request->input('department_id'); + $tenantId = session('selected_tenant_id'); + + if (! $departmentId) { + return view('department-permissions.partials.empty-state'); + } + + // 메뉴 트리 조회 + $menus = $this->departmentPermissionService->getMenuTree($tenantId); + + // 권한 매트릭스 조회 + $permissions = $this->departmentPermissionService->getDepartmentPermissionMatrix($departmentId, $tenantId); + + return view('department-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'departmentId' => $departmentId, + ]); + } + + /** + * 권한 토글 + */ + public function toggle(Request $request) + { + $departmentId = $request->input('department_id'); + $menuId = $request->input('menu_id'); + $permissionType = $request->input('permission_type'); + $tenantId = session('selected_tenant_id'); + + $newValue = $this->departmentPermissionService->togglePermission( + $departmentId, + $menuId, + $permissionType, + $tenantId + ); + + // 전체 매트릭스 다시 로드 + $menus = $this->departmentPermissionService->getMenuTree($tenantId); + $permissions = $this->departmentPermissionService->getDepartmentPermissionMatrix($departmentId, $tenantId); + + return view('department-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'departmentId' => $departmentId, + ]); + } + + /** + * 전체 허용 + */ + public function allowAll(Request $request) + { + $departmentId = $request->input('department_id'); + $tenantId = session('selected_tenant_id'); + + $this->departmentPermissionService->allowAllPermissions($departmentId, $tenantId); + + // 전체 매트릭스 다시 로드 + $menus = $this->departmentPermissionService->getMenuTree($tenantId); + $permissions = $this->departmentPermissionService->getDepartmentPermissionMatrix($departmentId, $tenantId); + + return view('department-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'departmentId' => $departmentId, + ]); + } + + /** + * 전체 거부 + */ + public function denyAll(Request $request) + { + $departmentId = $request->input('department_id'); + $tenantId = session('selected_tenant_id'); + + $this->departmentPermissionService->denyAllPermissions($departmentId, $tenantId); + + // 전체 매트릭스 다시 로드 + $menus = $this->departmentPermissionService->getMenuTree($tenantId); + $permissions = $this->departmentPermissionService->getDepartmentPermissionMatrix($departmentId, $tenantId); + + return view('department-permissions.partials.permission-matrix', [ + 'menus' => $menus, + 'permissions' => $permissions, + 'departmentId' => $departmentId, + ]); + } +} diff --git a/app/Http/Controllers/DepartmentPermissionController.php b/app/Http/Controllers/DepartmentPermissionController.php new file mode 100644 index 00000000..3a4fc5b8 --- /dev/null +++ b/app/Http/Controllers/DepartmentPermissionController.php @@ -0,0 +1,35 @@ +departmentPermissionService = $departmentPermissionService; + } + + /** + * 부서 권한 관리 메인 페이지 + */ + public function index(Request $request) + { + $tenantId = session('selected_tenant_id'); + + // 부서 목록 조회 + $departments = \App\Models\Tenants\Department::query(); + if ($tenantId && $tenantId !== 'all') { + $departments->where('tenant_id', $tenantId); + } + $departments = $departments->orderBy('sort_order')->orderBy('name')->get(); + + return view('department-permissions.index', [ + 'departments' => $departments, + ]); + } +} diff --git a/app/Services/DepartmentPermissionService.php b/app/Services/DepartmentPermissionService.php new file mode 100644 index 00000000..b8786638 --- /dev/null +++ b/app/Services/DepartmentPermissionService.php @@ -0,0 +1,298 @@ +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', 'like', 'menu:%'); + + if ($tenantId) { + $query->where('permissions.tenant_id', $tenantId); + } + + $departmentPermissions = $query->pluck('permissions.name')->toArray(); + + $permissions = []; + foreach ($departmentPermissions as $permName) { + if (preg_match('/^menu:(\d+)\.(\w+)$/', $permName, $matches)) { + $menuId = (int) $matches[1]; + $type = $matches[2]; + + if (! isset($permissions[$menuId])) { + $permissions[$menuId] = []; + } + + $permissions[$menuId][$type] = true; + } + } + + return $permissions; + } + + /** + * 특정 메뉴의 특정 권한 토글 + * + * @param int $departmentId 부서 ID + * @param int $menuId 메뉴 ID + * @param string $permissionType 권한 유형 + * @param int|null $tenantId 테넌트 ID + * @return bool 토글 후 상태 (true: 허용, false: 거부) + */ + public function togglePermission(int $departmentId, int $menuId, string $permissionType, ?int $tenantId = null): bool + { + $permissionName = "menu:{$menuId}.{$permissionType}"; + + // 권한 생성 또는 조회 + $permission = Permission::firstOrCreate( + ['name' => $permissionName, 'guard_name' => 'web', 'tenant_id' => $tenantId], + ['created_by' => auth()->id()] + ); + + // 현재 권한 상태 확인 + $exists = DB::table('model_has_permissions') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->exists(); + + if ($exists) { + // 권한 제거 + DB::table('model_has_permissions') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->delete(); + + $newValue = false; + } else { + // 권한 부여 + DB::table('model_has_permissions')->insert([ + 'permission_id' => $permission->id, + 'model_type' => Department::class, + 'model_id' => $departmentId, + ]); + + $newValue = true; + } + + // 하위 부서에 권한 전파 + $this->propagateToChildren($departmentId, $menuId, $permissionType, $newValue, $tenantId); + + return $newValue; + } + + /** + * 하위 부서에 권한 전파 + * + * @param int $parentDepartmentId 부모 부서 ID + * @param int $menuId 메뉴 ID + * @param string $permissionType 권한 유형 + * @param bool $value 권한 값 (true: 허용, false: 거부) + * @param int|null $tenantId 테넌트 ID + */ + protected function propagateToChildren(int $parentDepartmentId, int $menuId, string $permissionType, bool $value, ?int $tenantId = null): void + { + $children = Department::where('parent_id', $parentDepartmentId)->get(); + + foreach ($children as $child) { + $permissionName = "menu:{$menuId}.{$permissionType}"; + $permission = Permission::firstOrCreate( + ['name' => $permissionName, 'guard_name' => 'web', 'tenant_id' => $tenantId], + ['created_by' => auth()->id()] + ); + + if ($value) { + // 권한 부여 + $exists = DB::table('model_has_permissions') + ->where('model_type', Department::class) + ->where('model_id', $child->id) + ->where('permission_id', $permission->id) + ->exists(); + + if (! $exists) { + DB::table('model_has_permissions')->insert([ + 'permission_id' => $permission->id, + 'model_type' => Department::class, + 'model_id' => $child->id, + ]); + } + } else { + // 권한 제거 + DB::table('model_has_permissions') + ->where('model_type', Department::class) + ->where('model_id', $child->id) + ->where('permission_id', $permission->id) + ->delete(); + } + + // 재귀적으로 하위 부서 처리 + $this->propagateToChildren($child->id, $menuId, $permissionType, $value, $tenantId); + } + } + + /** + * 모든 권한 허용 + * + * @param int $departmentId 부서 ID + * @param int|null $tenantId 테넌트 ID + */ + public function allowAllPermissions(int $departmentId, ?int $tenantId = null): 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::firstOrCreate( + ['name' => $permissionName, 'guard_name' => 'web', 'tenant_id' => $tenantId], + ['created_by' => auth()->id()] + ); + + // 권한 부여 + $exists = DB::table('model_has_permissions') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->exists(); + + if (! $exists) { + DB::table('model_has_permissions')->insert([ + 'permission_id' => $permission->id, + 'model_type' => Department::class, + 'model_id' => $departmentId, + ]); + } + } + } + } + + /** + * 모든 권한 거부 + * + * @param int $departmentId 부서 ID + * @param int|null $tenantId 테넌트 ID + */ + public function denyAllPermissions(int $departmentId, ?int $tenantId = null): 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)->first(); + + if ($permission) { + DB::table('model_has_permissions') + ->where('model_type', Department::class) + ->where('model_id', $departmentId) + ->where('permission_id', $permission->id) + ->delete(); + } + } + } + } + + /** + * 메뉴 트리 조회 (권한 매트릭스 표시용) + * + * @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 현재 깊이 + * @return \Illuminate\Support\Collection + */ + 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; + } + + /** + * 특정 부서의 활성 메뉴 권한 확인 + * + * @param int $departmentId 부서 ID + * @param int $menuId 메뉴 ID + * @param string $permissionType 권한 유형 + * @return bool 권한 존재 여부 + */ + public function hasPermission(int $departmentId, int $menuId, string $permissionType): bool + { + $permissionName = "menu:{$menuId}.{$permissionType}"; + + 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) + ->exists(); + } +} diff --git a/resources/views/department-permissions/index.blade.php b/resources/views/department-permissions/index.blade.php new file mode 100644 index 00000000..7e2ef789 --- /dev/null +++ b/resources/views/department-permissions/index.blade.php @@ -0,0 +1,110 @@ +@extends('layouts.app') + +@section('title', '부서 권한 관리') + +@section('content') + +
+

부서 권한 관리

+
+ + +
+
+
+ 부서 선택: + @foreach($departments as $department) + + @endforeach +
+
+
+ + + + + +
+ @include('department-permissions.partials.empty-state') +
+ + +@endsection diff --git a/resources/views/department-permissions/partials/empty-state.blade.php b/resources/views/department-permissions/partials/empty-state.blade.php new file mode 100644 index 00000000..636b6477 --- /dev/null +++ b/resources/views/department-permissions/partials/empty-state.blade.php @@ -0,0 +1,9 @@ +
+ +

부서를 선택해주세요

+

+ 위의 부서 목록에서 권한을 관리할 부서를 선택하세요. +

+
diff --git a/resources/views/department-permissions/partials/permission-matrix.blade.php b/resources/views/department-permissions/partials/permission-matrix.blade.php new file mode 100644 index 00000000..23022f64 --- /dev/null +++ b/resources/views/department-permissions/partials/permission-matrix.blade.php @@ -0,0 +1,66 @@ +
+ + + + + + + + + + + + + + + + + + @php + $permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']; + @endphp + @forelse($menus as $index => $menu) + + + + + + @foreach($permissionTypes as $type) + + @endforeach + + @empty + + + + @endforelse + +
순번메뉴명URL순서조회생성수정삭제승인내보내기관리
+ {{ $index + 1 }} + +
+ @if(($menu->depth ?? 0) > 0) + + @endif + {{ $menu->name }} +
+
+ + {{ $menu->url }} + + + {{ $menu->sort_order }} + + id][$type]) && $permissions[$menu->id][$type] ? 'checked' : '' }} + class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" + hx-post="/api/admin/department-permissions/toggle" + hx-target="#permission-matrix" + hx-include="[name='department_id']" + hx-vals='{"menu_id": {{ $menu->id }}, "permission_type": "{{ $type }}"}' + > +
+ 활성화된 메뉴가 없습니다. +
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 284e1b24..5401aa52 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -112,8 +112,8 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
  • - diff --git a/routes/api.php b/routes/api.php index 51dd594d..2ca72399 100644 --- a/routes/api.php +++ b/routes/api.php @@ -103,4 +103,12 @@ Route::post('/allow-all', [RolePermissionController::class, 'allowAll'])->name('allowAll'); Route::post('/deny-all', [RolePermissionController::class, 'denyAll'])->name('denyAll'); }); + + // 부서 권한 관리 API + Route::prefix('department-permissions')->name('department-permissions.')->group(function () { + Route::get('/matrix', [\App\Http\Controllers\Api\Admin\DepartmentPermissionController::class, 'getMatrix'])->name('matrix'); + Route::post('/toggle', [\App\Http\Controllers\Api\Admin\DepartmentPermissionController::class, 'toggle'])->name('toggle'); + Route::post('/allow-all', [\App\Http\Controllers\Api\Admin\DepartmentPermissionController::class, 'allowAll'])->name('allowAll'); + Route::post('/deny-all', [\App\Http\Controllers\Api\Admin\DepartmentPermissionController::class, 'denyAll'])->name('denyAll'); + }); }); diff --git a/routes/web.php b/routes/web.php index 3b1fa43e..f8513722 100644 --- a/routes/web.php +++ b/routes/web.php @@ -78,6 +78,9 @@ // 역할 권한 관리 (Blade 화면만) Route::get('/role-permissions', [RolePermissionController::class, 'index'])->name('role-permissions.index'); + // 부서 권한 관리 (Blade 화면만) + Route::get('/department-permissions', [\App\Http\Controllers\DepartmentPermissionController::class, 'index'])->name('department-permissions.index'); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');