diff --git a/app/Http/Controllers/Api/Admin/PermissionController.php b/app/Http/Controllers/Api/Admin/PermissionController.php new file mode 100644 index 00000000..62bfc5bc --- /dev/null +++ b/app/Http/Controllers/Api/Admin/PermissionController.php @@ -0,0 +1,127 @@ +permissionService->getPermissions( + $request->all(), + $request->input('per_page', 20) + ); + + // HTMX 요청 시 부분 HTML 반환 + if ($request->header('HX-Request')) { + return view('permissions.partials.table', compact('permissions')); + } + + // 일반 요청 시 JSON 반환 + return response()->json([ + 'success' => true, + 'data' => $permissions->items(), + 'meta' => [ + 'current_page' => $permissions->currentPage(), + 'total' => $permissions->total(), + 'per_page' => $permissions->perPage(), + 'last_page' => $permissions->lastPage(), + ], + ]); + } + + /** + * 권한 생성 + */ + public function store(StorePermissionRequest $request): JsonResponse + { + try { + $permission = $this->permissionService->createPermission($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '권한이 생성되었습니다.', + 'data' => $permission, + ], 201); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 권한 상세 조회 + */ + public function show(int $id): JsonResponse + { + $permission = $this->permissionService->getPermissionById($id); + + if (! $permission) { + return response()->json([ + 'success' => false, + 'message' => '권한을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $permission, + ]); + } + + /** + * 권한 수정 + */ + public function update(UpdatePermissionRequest $request, int $id): JsonResponse + { + try { + $permission = $this->permissionService->updatePermission($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '권한이 수정되었습니다.', + 'data' => $permission, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 권한 삭제 + */ + public function destroy(int $id): JsonResponse + { + try { + $this->permissionService->deletePermission($id); + + return response()->json([ + 'success' => true, + 'message' => '권한이 삭제되었습니다.', + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } +} diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php new file mode 100644 index 00000000..14f3d6e7 --- /dev/null +++ b/app/Http/Controllers/PermissionController.php @@ -0,0 +1,36 @@ +get(); + + return view('permissions.create', compact('tenants')); + } + + /** + * 권한 수정 화면 + */ + public function edit($id) + { + $tenants = Tenant::orderBy('company_name')->get(); + + return view('permissions.edit', compact('id', 'tenants')); + } +} diff --git a/app/Http/Requests/StorePermissionRequest.php b/app/Http/Requests/StorePermissionRequest.php new file mode 100644 index 00000000..176b62b2 --- /dev/null +++ b/app/Http/Requests/StorePermissionRequest.php @@ -0,0 +1,73 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + $guardName = $this->input('guard_name', 'web'); + $tenantId = $this->input('tenant_id'); + + $service = app(PermissionService::class); + if ($service->isNameExists($value, $guardName, null, $tenantId)) { + $fail('이 권한 이름은 이미 사용 중입니다.'); + } + }, + ], + 'guard_name' => 'required|string|max:255', + 'tenant_id' => 'nullable|exists:tenants,id', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'name' => '권한 이름', + 'guard_name' => '가드 이름', + 'tenant_id' => '테넌트', + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => '권한 이름을 입력해주세요.', + 'name.max' => '권한 이름은 255자를 초과할 수 없습니다.', + 'guard_name.required' => '가드 이름을 입력해주세요.', + 'tenant_id.exists' => '존재하지 않는 테넌트입니다.', + ]; + } +} diff --git a/app/Http/Requests/UpdatePermissionRequest.php b/app/Http/Requests/UpdatePermissionRequest.php new file mode 100644 index 00000000..e61c2803 --- /dev/null +++ b/app/Http/Requests/UpdatePermissionRequest.php @@ -0,0 +1,75 @@ +|string> + */ + public function rules(): array + { + $permissionId = $this->route('id'); + + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + function ($attribute, $value, $fail) use ($permissionId) { + $guardName = $this->input('guard_name', 'web'); + $tenantId = $this->input('tenant_id'); + + $service = app(PermissionService::class); + if ($service->isNameExists($value, $guardName, $permissionId, $tenantId)) { + $fail('이 권한 이름은 이미 사용 중입니다.'); + } + }, + ], + 'guard_name' => 'required|string|max:255', + 'tenant_id' => 'nullable|exists:tenants,id', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'name' => '권한 이름', + 'guard_name' => '가드 이름', + 'tenant_id' => '테넌트', + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => '권한 이름을 입력해주세요.', + 'name.max' => '권한 이름은 255자를 초과할 수 없습니다.', + 'guard_name.required' => '가드 이름을 입력해주세요.', + 'tenant_id.exists' => '존재하지 않는 테넌트입니다.', + ]; + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php new file mode 100644 index 00000000..c9267596 --- /dev/null +++ b/app/Models/Permission.php @@ -0,0 +1,61 @@ +belongsTo(Tenant::class); + } + + /** + * 이 권한이 할당된 역할 목록 (현재 테넌트만) + */ + public function roles(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + $query = parent::roles(); + + $currentTenantId = session('selected_tenant_id'); + if ($currentTenantId && $currentTenantId !== 'all') { + $query->where('roles.tenant_id', $currentTenantId); + } + + return $query; + } + + /** + * 이 권한이 직접 할당된 부서 목록 (현재 테넌트만) + */ + public function departments(): \Illuminate\Database\Eloquent\Relations\MorphToMany + { + $query = $this->morphedByMany( + \App\Models\Tenants\Department::class, + 'model', + config('permission.table_names.model_has_permissions'), + 'permission_id', + 'model_id' + ); + + $currentTenantId = session('selected_tenant_id'); + if ($currentTenantId && $currentTenantId !== 'all') { + $query->where('departments.tenant_id', $currentTenantId); + } + + return $query; + } +} diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php new file mode 100644 index 00000000..0937647f --- /dev/null +++ b/app/Services/PermissionService.php @@ -0,0 +1,142 @@ +with('tenant', 'roles', 'departments'); + + // 검색 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + // 테넌트 필터 + $selectedTenantId = session('selected_tenant_id'); + if ($selectedTenantId && $selectedTenantId !== 'all') { + $query->where('tenant_id', $selectedTenantId); + } elseif (isset($filters['tenant_id'])) { + $query->where('tenant_id', $filters['tenant_id']); + } + + // guard_name 필터 + if (! empty($filters['guard_name'])) { + $query->where('guard_name', $filters['guard_name']); + } + + return $query->orderBy('id', 'desc')->paginate($perPage); + } + + /** + * 특정 권한 조회 + */ + public function getPermissionById(int $id): ?Permission + { + return Permission::with('tenant', 'roles')->find($id); + } + + /** + * 권한 생성 + */ + public function createPermission(array $data): Permission + { + $data['created_by'] = auth()->id(); + $data['guard_name'] = $data['guard_name'] ?? 'web'; + + return Permission::create($data); + } + + /** + * 권한 수정 + */ + public function updatePermission(int $id, array $data): Permission + { + $permission = Permission::findOrFail($id); + + $data['updated_by'] = auth()->id(); + + $permission->update($data); + + return $permission->fresh(); + } + + /** + * 권한 삭제 + */ + public function deletePermission(int $id): bool + { + $permission = Permission::findOrFail($id); + + // 할당된 역할이 있는지 확인 + if ($permission->roles()->count() > 0) { + throw new \Exception('이 권한이 할당된 역할이 있어 삭제할 수 없습니다.'); + } + + return $permission->delete(); + } + + /** + * 권한 이름 중복 체크 + */ + public function isNameExists(string $name, string $guardName, ?int $excludeId = null, ?int $tenantId = null): bool + { + $query = Permission::where('name', $name) + ->where('guard_name', $guardName); + + if ($tenantId !== null) { + $query->where('tenant_id', $tenantId); + } + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + /** + * 활성 권한 목록 (드롭다운용) + */ + public function getActivePermissions(?int $tenantId = null): \Illuminate\Database\Eloquent\Collection + { + $query = Permission::query(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + return $query->orderBy('name')->get(); + } + + /** + * 권한 통계 + */ + public function getPermissionStats(): array + { + $selectedTenantId = session('selected_tenant_id'); + $query = Permission::query(); + + if ($selectedTenantId && $selectedTenantId !== 'all') { + $query->where('tenant_id', $selectedTenantId); + } + + return [ + 'total' => $query->count(), + 'by_guard' => $query->select('guard_name', \DB::raw('count(*) as count')) + ->groupBy('guard_name') + ->pluck('count', 'guard_name') + ->toArray(), + ]; + } +} diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 8229442c..d6f5eba5 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -53,6 +53,17 @@ class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray- + +
  • + + + + + 권한 관리 + +
  • +
  • + + + + +
    +
    + @csrf + + +
    + + +

    영문 소문자, 점(.), 하이픈(-), 언더스코어(_)만 사용

    +
    + + +
    + + +

    기본값: web

    +
    + + +
    + + +

    선택하지 않으면 전체 테넌트에서 사용 가능한 마스터 권한

    +
    + + +
    + + + 취소 + +
    +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/permissions/edit.blade.php b/resources/views/permissions/edit.blade.php new file mode 100644 index 00000000..65cae7e7 --- /dev/null +++ b/resources/views/permissions/edit.blade.php @@ -0,0 +1,157 @@ +@extends('layouts.app') + +@section('title', '권한 수정') + +@section('content') +
    + +
    +

    🛡️ 권한 수정

    + + ← 목록으로 + +
    + + +
    +
    +
    +
    +
    + + +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/permissions/index.blade.php b/resources/views/permissions/index.blade.php new file mode 100644 index 00000000..b2a3dac1 --- /dev/null +++ b/resources/views/permissions/index.blade.php @@ -0,0 +1,92 @@ +@extends('layouts.app') + +@section('title', '권한 관리') + +@section('content') + + @include('partials.tenant-selector') + + +
    +

    🛡️ 권한 관리

    + + + 새 권한 + +
    + + +
    +
    + +
    + +
    + + +
    + +
    + + + +
    +
    + + +
    + +
    +
    +
    +
    +@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/permissions/partials/table.blade.php b/resources/views/permissions/partials/table.blade.php new file mode 100644 index 00000000..f20a70c1 --- /dev/null +++ b/resources/views/permissions/partials/table.blade.php @@ -0,0 +1,195 @@ +@php +use App\Models\Commons\Menu; + +/** + * 권한명을 파싱하여 메뉴 태그와 권한 타입을 시각적으로 표시 + */ +function formatPermissionName(string $permissionName): string +{ + // menu:{menu_id}.{permission_type} 패턴 파싱 + if (preg_match('/^menu:(\d+)\.(\w+)$/', $permissionName, $matches)) { + $menuId = (int) $matches[1]; + $permissionType = $matches[2]; + + // 메뉴 정보 조회 + $menu = Menu::find($menuId); + $menuName = $menu ? $menu->name : '알 수 없는 메뉴'; + + // 권한 타입별 설정 + $permissionConfig = getPermissionConfig($permissionType); + + // HTML 생성 + $html = '
    '; + + // 메뉴 태그 (회색 배지) + $html .= sprintf( + '메뉴 #%d', + $menuId + ); + + // 메뉴명 + $html .= sprintf( + '%s', + htmlspecialchars($menuName) + ); + + // 권한 타입 배지 + $html .= sprintf( + '%s', + $permissionConfig['class'], + $permissionConfig['label'], + $permissionConfig['badge'] + ); + + $html .= '
    '; + + return $html; + } + + // 패턴이 일치하지 않으면 원본 반환 + return '' . htmlspecialchars($permissionName) . ''; +} + +/** + * 권한 타입별 설정 반환 + */ +function getPermissionConfig(string $type): array +{ + $configs = [ + 'view' => [ + 'badge' => 'V', + 'label' => '조회', + 'class' => 'bg-blue-100 text-blue-800', + ], + 'create' => [ + 'badge' => 'C', + 'label' => '생성', + 'class' => 'bg-green-100 text-green-800', + ], + 'update' => [ + 'badge' => 'U', + 'label' => '수정', + 'class' => 'bg-orange-100 text-orange-800', + ], + 'delete' => [ + 'badge' => 'D', + 'label' => '삭제', + 'class' => 'bg-red-100 text-red-800', + ], + 'approve' => [ + 'badge' => 'A', + 'label' => '승인', + 'class' => 'bg-purple-100 text-purple-800', + ], + 'export' => [ + 'badge' => 'E', + 'label' => '내보내기', + 'class' => 'bg-sky-100 text-sky-600', + ], + 'manage' => [ + 'badge' => 'M', + 'label' => '관리', + 'class' => 'bg-gray-100 text-gray-800', + ], + ]; + + return $configs[$type] ?? [ + 'badge' => strtoupper(substr($type, 0, 1)), + 'label' => $type, + 'class' => 'bg-gray-100 text-gray-800', + ]; +} +@endphp + +
    + + + + + + + + + + + + + + + + @forelse($permissions as $permission) + + + + + + + + + + + + @empty + + + + @endforelse + +
    ID권한명테넌트가드할당된 역할할당된 부서생성일수정일액션
    + {{ $permission->id }} + + {!! formatPermissionName($permission->name) !!} + + {{ $permission->tenant?->company_name ?? '전역' }} + + + {{ $permission->guard_name }} + + + @if($permission->roles->isNotEmpty()) +
    + @foreach($permission->roles as $role) + + {{ $role->name }} + + @endforeach +
    + @else + - + @endif +
    + @if($permission->departments->isNotEmpty()) +
    + @foreach($permission->departments as $department) + + {{ $department->name }} + + @endforeach +
    + @else + - + @endif +
    + {{ $permission->created_at?->format('Y-m-d H:i') ?? '-' }} + + {{ $permission->updated_at?->format('Y-m-d H:i') ?? '-' }} + + + 수정 + + +
    + 등록된 권한이 없습니다. +
    +
    + + +@include('partials.pagination', [ + 'paginator' => $permissions, + 'target' => '#permission-table', + 'includeForm' => '#filterForm' +]) diff --git a/routes/api.php b/routes/api.php index 4a80d87f..f5e90b13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\Admin\DepartmentController; use App\Http\Controllers\Api\Admin\MenuController; +use App\Http\Controllers\Api\Admin\PermissionController; use App\Http\Controllers\Api\Admin\RoleController; use App\Http\Controllers\Api\Admin\TenantController; use App\Http\Controllers\Api\Admin\UserController; @@ -84,4 +85,13 @@ Route::post('/{id}/toggle-active', [MenuController::class, 'toggleActive'])->name('toggleActive'); Route::post('/{id}/toggle-hidden', [MenuController::class, 'toggleHidden'])->name('toggleHidden'); }); -}); \ No newline at end of file + + // 권한 관리 API + Route::prefix('permissions')->name('permissions.')->group(function () { + Route::get('/', [PermissionController::class, 'index'])->name('index'); + Route::post('/', [PermissionController::class, 'store'])->name('store'); + Route::get('/{id}', [PermissionController::class, 'show'])->name('show'); + Route::put('/{id}', [PermissionController::class, 'update'])->name('update'); + Route::delete('/{id}', [PermissionController::class, 'destroy'])->name('destroy'); + }); +}); diff --git a/routes/web.php b/routes/web.php index f9267683..7fb09baa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\DepartmentController; use App\Http\Controllers\MenuController; +use App\Http\Controllers\PermissionController; use App\Http\Controllers\RoleController; use App\Http\Controllers\TenantController; use App\Http\Controllers\UserController; @@ -66,6 +67,13 @@ Route::get('/{id}/edit', [MenuController::class, 'edit'])->name('edit'); }); + // 권한 관리 (Blade 화면만) + Route::prefix('permissions')->name('permissions.')->group(function () { + Route::get('/', [PermissionController::class, 'index'])->name('index'); + Route::get('/create', [PermissionController::class, 'create'])->name('create'); + Route::get('/{id}/edit', [PermissionController::class, 'edit'])->name('edit'); + }); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');