feat(mng): 개인 권한 관리 통합 매트릭스 구현

- 역할/부서/개인 권한을 통합하여 최종 유효 권한 표시
- 권한 소스별 색상 구분 UI (보라=역할, 파랑=부서, 녹색=개인허용, 빨강=개인거부)
- 스마트 토글 로직 (상속된 권한 오버라이드 지원)
- UserPermissionService: getRolePermissions(), getDepartmentPermissions(), getPersonalOverrides()
- 사용자 ID 뱃지 스타일 개선
This commit is contained in:
2025-11-26 20:40:54 +09:00
parent d4534f0d3f
commit 7546771ee5
13 changed files with 2411 additions and 86 deletions

View File

@@ -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 포맷팅 통과
---

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\UserPermissionService;
use Illuminate\Http\Request;
class UserPermissionController extends Controller
{
protected UserPermissionService $userPermissionService;
public function __construct(UserPermissionService $userPermissionService)
{
$this->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,
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Services\UserPermissionService;
use Illuminate\Http\Request;
class UserPermissionController extends Controller
{
protected UserPermissionService $userPermissionService;
public function __construct(UserPermissionService $userPermissionService)
{
$this->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,
]);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,710 @@
<?php
namespace App\Services;
use App\Models\Commons\Menu;
use App\Models\Permission;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class UserPermissionService
{
/**
* 권한 유형 목록
*/
private array $permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage'];
/**
* 사용자의 권한 매트릭스 조회 (역할 + 부서 + 개인 오버라이드 통합)
* 각 권한에 대해 최종 상태와 소스 정보 반환
*
* @param int $userId 사용자 ID
* @param int|null $tenantId 테넌트 ID
* @param string $guardName Guard 이름 (api 또는 web)
* @return array 메뉴별 권한 상태 매트릭스
* [menuId][type] = ['effective' => '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();
}
}

View File

@@ -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)

View File

@@ -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')
<!-- ① 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<!-- 제목 + 액션 버튼 -->
</div>
<!-- ② 필터 영역 (선택사항) -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<!-- 검색, 필터 폼 -->
</div>
<!-- ③ 테이블 영역 (HTMX) -->
<div id="{resource}-table"
hx-get="/api/admin/{resource}"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<!-- 테이블 partial 로드됨 -->
</div>
@endsection
```
### 1.2 파일 구조
```
resources/views/{resource}/
├── index.blade.php # 메인 페이지 (레이아웃만)
├── create.blade.php # 생성 폼
├── edit.blade.php # 수정 폼
└── partials/
└── table.blade.php # 테이블 + 페이지네이션
```
---
## 2. 페이지 헤더
### 2.1 기본 구조
```blade
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">{페이지 제목}</h1>
<a href="{{ route('{resource}.create') }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ {액션 버튼 레이블}
</a>
</div>
```
### 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
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4">
<!-- 검색 입력 -->
<div class="flex-1">
<input type="text"
name="search"
placeholder="{항목명}으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 드롭다운 필터 (선택사항) -->
<div class="w-48">
<select name="{filter_name}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 {필터명}</option>
<!-- 옵션들 -->
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit"
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
```
### 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
<div id="{resource}-table"
hx-get="/api/admin/{resource}"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
```
### 4.2 테이블 Partial (`partials/table.blade.php`)
```blade
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
{컬럼명}
</th>
<!-- 추가 컬럼들 -->
<th class="px-4 py-2 text-right text-sm font-semibold text-gray-700 uppercase tracking-wider">
액션
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($items as $item)
<tr>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ $item->속성 }}
</td>
<!-- 추가 컬럼들 -->
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('{resource}.edit', $item->id) }}"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
<button onclick="confirmDelete({{ $item->id }}, '{{ $item->name }}')"
class="text-red-600 hover:text-red-900">
삭제
</button>
</td>
</tr>
@empty
<tr>
<td colspan="{컬럼수}" class="px-6 py-12 text-center text-gray-500">
등록된 {항목명}이(가) 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
@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
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.375rem; background-color: rgb(219 234 254); color: rgb(30 64 175);">
{배지 텍스트}
</span>
```
#### 배지 색상 시스템
| 용도 | 배경색 (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
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
{배지 텍스트}
</span>
```
### 4.5 Empty State
```blade
@empty
<tr>
<td colspan="{컬럼수}" class="px-6 py-12 text-center text-gray-500">
등록된 {항목명}이(가) 없습니다.
</td>
</tr>
@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
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Tailwind CSS (이미 레이아웃에 포함) -->
```
### 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**: `<table>`, `<thead>`, `<tbody>` 사용
- **버튼 레이블**: 명확한 액션 설명
- **키보드 네비게이션**: 버튼과 링크에 포커스 가능
---
## 9. 예제 코드
### 9.1 최소 구현 예제
#### `resources/views/products/index.blade.php`
```blade
@extends('layouts.app')
@section('title', '제품 관리')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">제품 관리</h1>
<a href="{{ route('products.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 새 제품
</a>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4">
<div class="flex-1">
<input type="text"
name="search"
placeholder="제품명으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 -->
<div id="product-table"
hx-get="/api/admin/products"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#product-table', 'filterSubmit');
});
function confirmDelete(id, name) {
if (confirm(`"${name}" 제품을 삭제하시겠습니까?`)) {
fetch(`/api/admin/products/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
htmx.trigger('#product-table', 'filterSubmit');
alert(data.message);
} else {
alert(data.message);
}
})
.catch(error => {
alert('제품 삭제 중 오류가 발생했습니다.');
});
}
}
</script>
@endpush
```
#### `resources/views/products/partials/table.blade.php`
```blade
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">제품명</th>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">생성일</th>
<th class="px-4 py-2 text-right text-sm font-semibold text-gray-700 uppercase tracking-wider">액션</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($products as $product)
<tr>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ $product->id }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ $product->name }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{{ $product->created_at?->format('Y-m-d H:i') ?? '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('products.edit', $product->id) }}"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
<button onclick="confirmDelete({{ $product->id }}, '{{ $product->name }}')"
class="text-red-600 hover:text-red-900">
삭제
</button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
등록된 제품이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@include('partials.pagination', [
'paginator' => $products,
'target' => '#product-table',
'includeForm' => '#filterForm'
])
```
---
## 10. 문서 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------|
| 1.0 | 2025-11-25 | Claude | 초안 작성 (권한 관리 페이지 기반) |
---
## 11. 문의
이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.

View File

@@ -122,8 +122,8 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
</a>
</li>
<li>
<a href="#"
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
<a href="{{ route('user-permissions.index') }}"
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('user-permissions.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
style="padding-left: 2rem;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />

View File

@@ -3,16 +3,16 @@
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">회사명</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">코드</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">이메일</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">전화번호</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">사용자</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">부서</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">역할</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">생성일</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">회사명</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">코드</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">이메일</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">전화번호</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">사용자</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">부서</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">역할</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">생성일</th>
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-700 uppercase tracking-wider">액션</th>
</tr>
</thead>
@@ -22,16 +22,16 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $tenant->id }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-3 py-2 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $tenant->company_name }}</div>
@if($tenant->ceo_name)
<div class="text-sm text-gray-500">대표: {{ $tenant->ceo_name }}</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-900">
{{ $tenant->code }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{{ $tenant->status_badge_color === 'success' ? 'bg-green-100 text-green-800' : '' }}
{{ $tenant->status_badge_color === 'warning' ? 'bg-yellow-100 text-yellow-800' : '' }}
@@ -39,25 +39,25 @@
{{ $tenant->status_label }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $tenant->email ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $tenant->phone ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
<td class="px-3 py-2 whitespace-nowrap text-sm text-center text-gray-900">
{{ $tenant->users_count ?? 0 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
<td class="px-3 py-2 whitespace-nowrap text-sm text-center text-gray-900">
{{ $tenant->departments_count ?? 0 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
<td class="px-3 py-2 whitespace-nowrap text-sm text-center text-gray-900">
{{ $tenant->menus_count ?? 0 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
<td class="px-3 py-2 whitespace-nowrap text-sm text-center text-gray-900">
{{ $tenant->roles_count ?? 0 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $tenant->created_at?->format('Y-m-d') ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">

View File

@@ -0,0 +1,172 @@
@extends('layouts.app')
@section('title', '개인 권한 관리')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">개인 권한 관리</h1>
</div>
<!-- 사용자 선택 -->
<div class="bg-white rounded-lg shadow-sm mb-6">
<div class="px-6 py-4">
@if($requireTenant)
{{-- 전체 테넌트 선택 : 테넌트 선택 안내 --}}
<div class="text-center py-8">
<div class="text-yellow-500 text-4xl mb-3">⚠️</div>
<p class="text-gray-700 font-medium mb-2">테넌트를 선택해주세요</p>
<p class="text-gray-500 text-sm">개인 권한을 관리하려면 상단 헤더에서 특정 테넌트를 선택해야 합니다.</p>
</div>
@else
{{-- 특정 테넌트 선택 : 사용자 목록 표시 --}}
@if($users->isEmpty())
<div class="text-center py-8">
<div class="text-gray-400 text-4xl mb-3">👤</div>
<p class="text-gray-700 font-medium mb-2">사용자가 없습니다</p>
<p class="text-gray-500 text-sm">선택한 테넌트에 등록된 사용자가 없습니다.</p>
</div>
@else
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm font-medium text-gray-700">사용자 선택:</span>
@foreach($users as $user)
<button
type="button"
class="user-button px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-white text-gray-700 border-gray-300 hover:bg-gray-50 inline-flex items-center gap-2"
data-user-id="{{ $user->id }}"
data-user-name="{{ $user->name }}"
data-user-login="{{ $user->user_id }}"
data-auto-select="{{ $loop->first ? 'true' : 'false' }}"
hx-get="/api/admin/user-permissions/matrix"
hx-target="#permission-matrix"
hx-include="[name='guard_name']"
hx-vals='{"user_id": {{ $user->id }}}'
onclick="selectUser(this)"
>
{{ $user->name }}
<span class="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">&nbsp;{{ $user->user_id }}&nbsp;</span>
</button>
@endforeach
</div>
@endif
@endif
</div>
</div>
<!-- 액션 버튼 -->
<div class="bg-white rounded-lg shadow-sm mb-6" id="action-buttons" style="display: none;">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700" id="selected-user-name">선택된 사용자</span>
<div class="flex items-center gap-2">
<input type="hidden" name="user_id" id="userIdInput" value="">
<!-- Guard 선택 -->
<span class="text-sm font-medium text-gray-700">Guard:</span>
<select
id="guardNameSelect"
name="guard_name"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
onchange="reloadPermissions()"
>
<option value="api" selected>API</option>
<option value="web">Web</option>
</select>
<!-- 구분선 -->
<div class="h-8 w-px bg-gray-300 mx-1"></div>
<button
type="button"
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
hx-post="/api/admin/user-permissions/allow-all"
hx-target="#permission-matrix"
hx-include="[name='user_id'],[name='guard_name']"
>
전체 허용
</button>
<button
type="button"
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
hx-post="/api/admin/user-permissions/deny-all"
hx-target="#permission-matrix"
hx-include="[name='user_id'],[name='guard_name']"
>
전체 거부
</button>
<button
type="button"
class="px-4 py-2 bg-gray-500 text-white text-sm font-medium rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-400"
hx-post="/api/admin/user-permissions/reset"
hx-target="#permission-matrix"
hx-include="[name='user_id'],[name='guard_name']"
title="모든 메뉴의 조회(view) 권한만 허용"
>
초기화
</button>
</div>
</div>
</div>
</div>
<!-- 권한 매트릭스 테이블 -->
<div id="permission-matrix" class="bg-white rounded-lg shadow-sm">
@include('user-permissions.partials.empty-state')
</div>
<script>
function selectUser(button) {
// 모든 버튼의 활성 상태 제거
document.querySelectorAll('.user-button').forEach(btn => {
btn.classList.remove('bg-blue-700', 'text-white', 'border-blue-700', 'hover:bg-blue-800');
btn.classList.add('bg-white', 'text-gray-700', 'border-gray-300', 'hover:bg-gray-50');
// 내부 뱃지 스타일 복원
const badge = btn.querySelector('span');
if (badge) {
badge.classList.remove('bg-blue-500', 'text-white');
badge.classList.add('bg-gray-200', 'text-gray-600');
}
});
// 클릭된 버튼 활성화
button.classList.remove('bg-white', 'text-gray-700', 'border-gray-300', 'hover:bg-gray-50');
button.classList.add('bg-blue-700', 'text-white', 'border-blue-700', 'hover:bg-blue-800');
// 내부 뱃지 스타일 변경
const activeBadge = button.querySelector('span');
if (activeBadge) {
activeBadge.classList.remove('bg-gray-200', 'text-gray-600');
activeBadge.classList.add('bg-blue-500', 'text-white');
}
// 사용자 정보 저장
const userId = button.getAttribute('data-user-id');
const userName = button.getAttribute('data-user-name');
const userLogin = button.getAttribute('data-user-login');
document.getElementById('userIdInput').value = userId;
document.getElementById('selected-user-name').innerHTML = `${userName} <span class="px-3 py-1 text-sm text-blue-600 border border-blue-400 rounded ml-2">&nbsp;${userLogin}&nbsp;</span>`;
// 액션 버튼 표시
document.getElementById('action-buttons').style.display = 'block';
}
// Guard 변경 시 권한 매트릭스 새로고침
function reloadPermissions() {
const selectedButton = document.querySelector('.user-button.bg-blue-700');
if (selectedButton) {
htmx.trigger(selectedButton, 'click');
}
}
// 페이지 로드 시 첫 번째 사용자 자동 선택 (특정 테넌트 선택 시에만)
document.addEventListener('DOMContentLoaded', function() {
const autoSelectButton = document.querySelector('.user-button[data-auto-select="true"]');
if (autoSelectButton) {
// onclick 핸들러 실행
selectUser(autoSelectButton);
// HTMX 이벤트 트리거
htmx.trigger(autoSelectButton, 'click');
}
});
</script>
@endsection

View File

@@ -0,0 +1,17 @@
<div class="px-6 py-12 text-center">
<div class="mx-auto max-w-lg">
<div class="mb-4 flex justify-center">
<div class="rounded-full bg-gray-100 p-3">
<svg class="h-6 w-6 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
</div>
<h4 class="text-base font-semibold leading-6 text-gray-950">
사용자를 선택해주세요
</h4>
<p class="mt-2 text-sm text-gray-600">
상단에서 사용자를 선택하면 해당 사용자의 개인 권한을 설정할 있습니다.
</p>
</div>
</div>

View File

@@ -0,0 +1,163 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 60px;">순번</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">메뉴명</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">URL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">순서</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">조회</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">생성</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">수정</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">삭제</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">승인</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 80px;">내보내기</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">관리</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@php
$permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage'];
@endphp
@forelse($menus as $index => $menu)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
{{ $index + 1 }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center text-sm text-gray-900" style="padding-left: {{ ($menu->depth ?? 0) * 20 }}px;">
@if(($menu->depth ?? 0) > 0)
<span class="mr-2 text-gray-400"></span>
@endif
<span>{{ $menu->name }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span class="truncate max-w-xs inline-block" title="{{ $menu->url }}">
{{ $menu->url }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
{{ $menu->sort_order }}
</td>
@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
<td class="px-6 py-4 whitespace-nowrap text-center">
<button
type="button"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-1 {{ $bgClass }}"
title="{{ $title }}"
hx-post="/api/admin/user-permissions/toggle"
hx-target="#permission-matrix"
hx-include="[name='user_id'],[name='guard_name']"
hx-vals='{"menu_id": {{ $menu->id }}, "permission_type": "{{ $type }}"}'
>
@if($icon === 'allow')
{{-- 허용: 체크 아이콘 --}}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
</svg>
@elseif($icon === 'deny')
{{-- 거부: X 아이콘 --}}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"></path>
</svg>
@else
{{-- 미설정: 마이너스 아이콘 --}}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"></path>
</svg>
@endif
</button>
</td>
@endforeach
</tr>
@empty
<tr>
<td colspan="11" class="px-6 py-12 text-center text-gray-500">
활성화된 메뉴가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- 범례 --}}
<div class="mt-4 flex flex-wrap items-center gap-6 text-sm text-gray-600 px-6 pb-4">
<span class="font-medium">범례:</span>
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-gray-100 text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"></path>
</svg>
</span>
<span>미설정</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-purple-100 text-purple-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
</svg>
</span>
<span>역할</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-blue-100 text-blue-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
</svg>
</span>
<span>부서</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-green-100 text-green-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
</svg>
</span>
<span>개인 허용</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-red-100 text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</span>
<span>개인 거부</span>
</div>
</div>

View File

@@ -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');