feat(mng): 개인 권한 관리 통합 매트릭스 구현
- 역할/부서/개인 권한을 통합하여 최종 유효 권한 표시 - 권한 소스별 색상 구분 UI (보라=역할, 파랑=부서, 녹색=개인허용, 빨강=개인거부) - 스마트 토글 로직 (상속된 권한 오버라이드 지원) - UserPermissionService: getRolePermissions(), getDepartmentPermissions(), getPersonalOverrides() - 사용자 ID 뱃지 스타일 개선
This commit is contained in:
195
CURRENT_WORKS.md
195
CURRENT_WORKS.md
@@ -1165,3 +1165,198 @@ ### 다음 단계:
|
||||
- [ ] 커밋 및 문서화
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-26 (화) - 개인 권한 관리 구현 및 permission_overrides 테이블 통일
|
||||
|
||||
### 주요 작업
|
||||
- 개인 권한 관리 (UserPermission) 기능 완전 구현
|
||||
- 기존 model_has_permissions 테이블 → permission_overrides 테이블로 통일
|
||||
- API AccessService와 동일한 테이블 구조 사용
|
||||
|
||||
### 추가된 파일:
|
||||
- `app/Http/Controllers/UserPermissionController.php` - Blade 화면 컨트롤러
|
||||
- `app/Http/Controllers/Api/Admin/UserPermissionController.php` - HTMX API 컨트롤러
|
||||
- `resources/views/user-permissions/index.blade.php` - 메인 페이지
|
||||
- `resources/views/user-permissions/partials/permission-matrix.blade.php` - 권한 매트릭스
|
||||
- `resources/views/user-permissions/partials/empty-state.blade.php` - 빈 상태 화면
|
||||
|
||||
### 수정된 파일:
|
||||
- `app/Services/UserPermissionService.php` - model_has_permissions → permission_overrides 테이블 사용
|
||||
- `app/Services/DepartmentPermissionService.php` - model_has_permissions → permission_overrides 테이블 사용
|
||||
- `routes/web.php` - 개인 권한 관리 라우트 추가
|
||||
- `routes/api.php` - 개인 권한 관리 API 라우트 추가
|
||||
- `resources/views/partials/sidebar.blade.php` - 개인 권한 관리 메뉴 링크 연결
|
||||
|
||||
### 기술 상세:
|
||||
**permission_overrides 테이블 통일:**
|
||||
- `model_type`: 폴리모픽 타입 (User, Department)
|
||||
- `model_id`: 대상 ID
|
||||
- `permission_id`: 권한 ID
|
||||
- `effect`: 0=DENY, 1=ALLOW (현재 ALLOW만 관리)
|
||||
- `effective_from`, `effective_to`: 유효 기간 지원
|
||||
- Soft delete 지원 (deleted_at, deleted_by)
|
||||
|
||||
**권한 체크 우선순위 (API AccessService):**
|
||||
1. 개인 DENY → 거부
|
||||
2. Role 권한 (Spatie can) → 허용
|
||||
3. 부서 ALLOW → 허용
|
||||
4. 개인 ALLOW → 허용
|
||||
5. 기본 → 거부
|
||||
|
||||
### 코드 품질:
|
||||
- ✅ PHP 문법 검사 통과
|
||||
- ✅ Pint 포맷팅 통과
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-26 (화) - 역할/부서 권한 관리 테넌트별 그룹핑
|
||||
|
||||
### 주요 작업
|
||||
- "전체" 테넌트 선택 시 역할/부서가 혼합 표시되는 문제 해결
|
||||
- 테넌트별로 그룹핑하여 가독성 향상
|
||||
|
||||
### 수정된 파일:
|
||||
- `app/Http/Controllers/RolePermissionController.php` - 테넌트별 역할 그룹핑 로직 추가
|
||||
- `app/Http/Controllers/DepartmentPermissionController.php` - 테넌트별 부서 그룹핑 로직 추가
|
||||
- `resources/views/role-permissions/index.blade.php` - 테넌트별 섹션 헤더 및 그룹핑 UI
|
||||
- `resources/views/department-permissions/index.blade.php` - 테넌트별 섹션 헤더 및 그룹핑 UI
|
||||
|
||||
### 기술 구현:
|
||||
**Controller 로직:**
|
||||
```php
|
||||
if ($tenantId && $tenantId !== 'all') {
|
||||
// 특정 테넌트 선택 시 기존 방식
|
||||
$roles = $rolesQuery->where('tenant_id', $tenantId)->get();
|
||||
$rolesByTenant = null;
|
||||
} else {
|
||||
// 전체 선택 시 테넌트별 그룹핑
|
||||
$roles = $rolesQuery->get();
|
||||
$rolesByTenant = $roles->groupBy('tenant_id');
|
||||
$tenantIds = $rolesByTenant->keys()->filter()->toArray();
|
||||
$tenants = Tenant::whereIn('id', $tenantIds)->pluck('company_name', 'id');
|
||||
}
|
||||
```
|
||||
|
||||
**View UI:**
|
||||
- 전체 테넌트 선택 시: 테넌트별 섹션으로 구분 (회색 라벨)
|
||||
- 선택된 역할/부서 표시: `[테넌트명] 역할명 역할` 형식
|
||||
- 기존 단일 테넌트 선택 시: 기존 UI 유지
|
||||
|
||||
### 이슈 해결:
|
||||
- **문제**: `tenants` 테이블에 `name` 컬럼 없음 (SQL 에러)
|
||||
- **원인**: 테넌트 테이블은 `company_name` 컬럼 사용
|
||||
- **해결**: `pluck('name', 'id')` → `pluck('company_name', 'id')` 변경
|
||||
|
||||
### Git 커밋:
|
||||
- ✅ `f029d78` "역할/부서 권한 관리 페이지 테넌트별 그룹핑 기능 추가"
|
||||
|
||||
### 문서화:
|
||||
- ✅ `docs/[MNG-2025-11-26] role-department-permission-tenant-grouping.md` 작성
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-26 (화) - 개인 권한 관리 3-state 토글 UI 구현
|
||||
|
||||
### 주요 작업
|
||||
- 개인 권한 관리에서 허용/거부/미설정 3단계 상태 지원
|
||||
- 체크박스 → 토글 버튼 UI로 변경
|
||||
- 권한 상태 순환: 미설정 → 허용 → 거부 → 미설정
|
||||
|
||||
### 수정된 파일:
|
||||
- `app/Services/UserPermissionService.php`
|
||||
- `getUserPermissionMatrix()`: 3-state 지원 (null/'allow'/'deny' 반환)
|
||||
- `togglePermission()`: 3단계 순환 로직 (null→allow→deny→null)
|
||||
- `allowAllPermissions()`: DENY 레코드도 ALLOW로 변경
|
||||
- `denyAllPermissions()`: ALLOW/DENY 모두 soft delete (미설정으로 초기화)
|
||||
- `resources/views/user-permissions/partials/permission-matrix.blade.php`
|
||||
- 체크박스 → 토글 버튼으로 변경
|
||||
- 3가지 상태별 아이콘 및 색상:
|
||||
- 미설정: ➖ (회색)
|
||||
- 허용: ✓ (녹색)
|
||||
- 거부: ✕ (빨간색)
|
||||
- 범례 추가 (하단)
|
||||
|
||||
### UI/UX 변경사항:
|
||||
**토글 버튼 디자인:**
|
||||
```
|
||||
미설정: bg-gray-100 text-gray-400 (➖)
|
||||
허용: bg-green-100 text-green-600 (✓)
|
||||
거부: bg-red-100 text-red-600 (✕)
|
||||
```
|
||||
|
||||
**토글 순환:**
|
||||
```
|
||||
클릭 시: 미설정 → 허용 → 거부 → 미설정 (무한 순환)
|
||||
```
|
||||
|
||||
**DB 상태:**
|
||||
- 미설정: 레코드 없음 또는 soft delete (deleted_at)
|
||||
- 허용: effect=1, deleted_at=null
|
||||
- 거부: effect=0, deleted_at=null
|
||||
|
||||
### API AccessService 권한 체크 우선순위:
|
||||
1. 개인 DENY → 즉시 거부
|
||||
2. Role 권한 (Spatie can) → 허용
|
||||
3. 부서 ALLOW → 허용
|
||||
4. 개인 ALLOW → 허용
|
||||
5. 기본 → 거부
|
||||
|
||||
### 코드 품질:
|
||||
- ✅ PHP 문법 검사 통과
|
||||
- ✅ Pint 포맷팅 통과
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-26 (화) - 개인 권한 관리 통합 매트릭스 구현
|
||||
|
||||
### 주요 작업
|
||||
- 역할/부서/개인 권한을 통합하여 최종 유효 권한 표시
|
||||
- 권한 소스별 색상 구분 (역할=보라, 부서=파랑, 개인허용=녹색, 개인거부=빨강)
|
||||
- 스마트 토글 로직 구현 (상속된 권한 오버라이드 지원)
|
||||
|
||||
### 수정된 파일:
|
||||
- `app/Services/UserPermissionService.php` - 역할/부서/개인 권한 통합 조회
|
||||
- `getRolePermissions()`: Spatie `model_has_roles` + `role_has_permissions` 조회
|
||||
- `getDepartmentPermissions()`: `permission_overrides` 테이블 (Department 모델타입) 조회
|
||||
- `getPersonalOverrides()`: `permission_overrides` 테이블 (User 모델타입) 조회
|
||||
- `getUserPermissionMatrix()`: 모든 권한 소스 통합, 소스 정보 반환
|
||||
- `togglePermission()`: 스마트 토글 로직
|
||||
- `resources/views/user-permissions/index.blade.php` - 사용자 ID 뱃지 스타일 개선
|
||||
- `resources/views/user-permissions/partials/permission-matrix.blade.php` - 5가지 상태 UI
|
||||
|
||||
### 기술 구현:
|
||||
|
||||
**권한 소스 우선순위 (표시용):**
|
||||
1. 개인 DENY → 최우선 (빨간색)
|
||||
2. 개인 ALLOW → 녹색
|
||||
3. 역할 권한 (Spatie) → 보라색
|
||||
4. 부서 권한 → 파란색
|
||||
5. 미설정 → 회색
|
||||
|
||||
**스마트 토글 로직:**
|
||||
```
|
||||
- 역할/부서 권한 (개인 설정 없음) → 클릭 시 개인 DENY 추가 (오버라이드)
|
||||
- 개인 DENY → 클릭 시 삭제 (상속된 권한 복원)
|
||||
- 권한 없음 → 클릭 시 개인 ALLOW 추가
|
||||
- 개인 ALLOW → 클릭 시 개인 DENY로 변경
|
||||
```
|
||||
|
||||
**색상 코드:**
|
||||
```
|
||||
보라색 (bg-purple-100): 역할에서 상속된 권한
|
||||
파란색 (bg-blue-100): 부서에서 상속된 권한
|
||||
녹색 (bg-green-100): 개인 허용 설정
|
||||
빨간색 (bg-red-100): 개인 거부 설정
|
||||
회색 (bg-gray-100): 미설정
|
||||
```
|
||||
|
||||
**UI 개선:**
|
||||
- 사용자 선택 버튼: 이름 + 아이디 뱃지 (` ` 패딩)
|
||||
- 선택된 사용자 표시: 테두리 스타일 (`border border-blue-400`)
|
||||
- 범례 업데이트: 5가지 상태 모두 표시
|
||||
|
||||
### 코드 품질:
|
||||
- ✅ PHP 문법 검사 통과
|
||||
- ✅ Pint 포맷팅 통과
|
||||
|
||||
---
|
||||
|
||||
161
app/Http/Controllers/Api/Admin/UserPermissionController.php
Normal file
161
app/Http/Controllers/Api/Admin/UserPermissionController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/UserPermissionController.php
Normal file
42
app/Http/Controllers/UserPermissionController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
710
app/Services/UserPermissionService.php
Normal file
710
app/Services/UserPermissionService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
669
docs/TABLE_LAYOUT_STANDARD.md
Normal file
669
docs/TABLE_LAYOUT_STANDARD.md
Normal 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. 문의
|
||||
|
||||
이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
172
resources/views/user-permissions/index.blade.php
Normal file
172
resources/views/user-permissions/index.blade.php
Normal 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"> {{ $user->user_id }} </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"> ${userLogin} </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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user