feat: Phase 4-2 역할 관리 시스템 구현 (HTMX + API 패턴)

- RoleService, RoleController (Blade/API) 생성
- 역할 CRUD 기능 완성 (목록/생성/수정/삭제)
- FormRequest 검증 (StoreRoleRequest, UpdateRoleRequest)
- HTMX 패턴 적용 (index, create, edit)
- 권한 선택 UI (체크박스, 전체 선택/해제)
- Tenant Selector 통합
- 레이아웃 패턴 문서화 (LAYOUT_PATTERN.md)
- Sidebar 메뉴에 역할 관리 추가
- Pagination partial 컴포넌트 추가
- Tenants 레이아웃 100% 폭으로 통일

주요 수정:
- UpdateRoleRequest 라우트 파라미터 수정 (role → id)
- RoleController permissions 조회 시 description 제거
- Conditional tenant filtering 적용
This commit is contained in:
2025-11-24 16:36:02 +09:00
parent 2752a6b7c3
commit 5f50716d7f
17 changed files with 1334 additions and 109 deletions

View File

@@ -310,4 +310,113 @@ ### 기술적 결정:
- **모든 페이지 공통**: `@include('partials.tenant-selector')` 사용
### Git 커밋:
- `661c5ad` "feat: 테넌트 선택 기능 구현"
- `661c5ad` "feat: 테넌트 선택 기능 구현"
---
## 2025-11-24 (일) - 테넌트 관리 페이지네이션 및 공통 컴포넌트화
### 주요 작업
- 테넌트 목록 페이지에 HTMX 호환 페이지네이션 추가
- 페이지네이션을 공통 컴포넌트로 분리하여 재사용성 향상
### 추가된 파일:
- `resources/views/partials/pagination.blade.php` - HTMX 호환 공통 페이지네이션 컴포넌트
### 수정된 파일:
- `resources/views/tenants/partials/table.blade.php` - 페이지네이션 코드 제거, 공통 컴포넌트 include로 대체
### 작업 배경:
**이전 세션 작업 내역 (2025-11-24):**
1. 테넌트 수정 HTMX URL 라우팅 문제 해결
2. UpdateTenantRequest 라우트 파라미터 수정 (`route('tenant')` `route('id')`)
3. HTMX 삭제/복원/영구삭제 기능에 CSRF 토큰 헤더 추가
4. TenantService::forceDeleteTenant() 외래키 제약 처리
5. `docs/TROUBLESHOOTING.md` 트러블슈팅 가이드 작성
6. 페이지네이션 추가 (1페이지만 있어도 항상 표시)
7. 페이지네이션을 공통 컴포넌트로 분리
### 페이지네이션 구현 상세:
**구조:**
- **모바일**: 이전/다음 버튼만 표시 (`sm:hidden`)
- **데스크톱**: 전체 페이지 번호 + 이전/다음 버튼 (`hidden sm:flex`)
**기능:**
- 개별 페이지 번호 버튼 클릭 가능 (1, 2, 3...)
- 현재 페이지 하이라이트 (파란색 배경)
- 비활성화된 버튼 회색 처리
- HTMX 동적 로딩 (페이지 새로고침 없음)
- 검색 필터 유지 (`hx-include`)
- CSRF 토큰 자동 포함
**사용법:**
```blade
@include('partials.pagination', [
'paginator' => $tenants, // LengthAwarePaginator 객체
'target' => '#tenant-table', // HTMX 타겟 ID
'includeForm' => '#filterForm' // 필터 폼 ID (선택)
])
```
**코드 개선:**
- 중복 코드 95줄 5줄 (include ) 간소화
- 유지보수성 향상 ( 곳만 수정하면 전체 적용)
- 다른 목록 페이지에서도 즉시 재사용 가능
### 기술적 특징:
**HTMX 통합:**
- `hx-get`: 페이지 URL 동적 로드
- `hx-target`: 교체할 DOM 영역 지정
- `hx-include`: 검색 필터 데이터 포함
- `hx-headers`: CSRF 토큰 자동 전달
**Laravel 페이지네이션 메서드:**
- `$paginator->total()`: 전체 데이터 개수
- `$paginator->firstItem()`: 현재 페이지 번째 항목 번호
- `$paginator->lastItem()`: 현재 페이지 마지막 항목 번호
- `$paginator->currentPage()`: 현재 페이지 번호
- `$paginator->lastPage()`: 마지막 페이지 번호
- `$paginator->getUrlRange(1, $lastPage)`: 페이지 URL 배열
- `$paginator->previousPageUrl()`: 이전 페이지 URL
- `$paginator->nextPageUrl()`: 다음 페이지 URL
- `$paginator->onFirstPage()`: 페이지 여부
- `$paginator->hasMorePages()`: 다음 페이지 존재 여부
**반응형 디자인:**
- 모바일: 간단한 이전/다음 네비게이션
- 데스크톱: 전체 페이지 번호 + 이전/다음
- Tailwind breakpoint: `sm:` (640px)
### 파일 구조:
```
resources/views/
├── partials/
│ ├── header.blade.php
│ ├── sidebar.blade.php
│ ├── tenant-selector.blade.php
│ └── pagination.blade.php ← 새로 추가된 공통 컴포넌트
└── tenants/
└── partials/
└── table.blade.php ← 페이지네이션 코드 제거, include로 대체
```
### 향후 활용:
공통 페이지네이션 컴포넌트는 다음 목록에서도 사용 가능:
- 사용자 목록
- 부서 목록
- 제품 목록
- 자재 목록
- BOM 목록
- 감사 로그 목록
### 다음 단계:
- [ ] 브라우저에서 페이지네이션 동작 확인
- [ ] 다른 목록 페이지에 페이지네이션 적용
- [ ] 페이지당 표시 개수 선택 기능 추가 고려
### Git 상태:
- 🔄 수정됨: `resources/views/tenants/partials/table.blade.php`
- 추가됨: `resources/views/partials/pagination.blade.php`
- 커밋 대기 (사용자 확인 커밋 예정)

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRoleRequest;
use App\Http\Requests\UpdateRoleRequest;
use App\Services\RoleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RoleController extends Controller
{
public function __construct(
private readonly RoleService $roleService
) {}
/**
* 역할 목록 조회
*/
public function index(Request $request): JsonResponse
{
$roles = $this->roleService->getRoles(
$request->all(),
$request->integer('per_page', 15)
);
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
$html = view('roles.partials.table', compact('roles'))->render();
return response()->json([
'html' => $html,
]);
}
// 일반 요청 시 JSON 반환
return response()->json([
'success' => true,
'data' => $roles->items(),
'meta' => [
'current_page' => $roles->currentPage(),
'last_page' => $roles->lastPage(),
'per_page' => $roles->perPage(),
'total' => $roles->total(),
],
]);
}
/**
* 역할 생성
*/
public function store(StoreRoleRequest $request): JsonResponse
{
$role = $this->roleService->createRole($request->validated());
// HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '역할이 생성되었습니다.',
'redirect' => route('roles.index'),
]);
}
return response()->json([
'success' => true,
'message' => '역할이 생성되었습니다.',
'data' => $role,
], 201);
}
/**
* 특정 역할 조회
*/
public function show(Request $request, int $id): JsonResponse
{
$role = $this->roleService->getRoleById($id);
if (!$role) {
return response()->json([
'success' => false,
'message' => '역할을 찾을 수 없습니다.',
], 404);
}
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
return response()->json([
'html' => view('roles.partials.detail', compact('role'))->render(),
]);
}
return response()->json([
'success' => true,
'data' => $role,
]);
}
/**
* 역할 수정
*/
public function update(UpdateRoleRequest $request, int $id): JsonResponse
{
$this->roleService->updateRole($id, $request->validated());
// HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '역할이 수정되었습니다.',
'redirect' => route('roles.index'),
]);
}
return response()->json([
'success' => true,
'message' => '역할이 수정되었습니다.',
]);
}
/**
* 역할 삭제 (Soft Delete)
*/
public function destroy(Request $request, int $id): JsonResponse
{
$this->roleService->deleteRole($id);
// HTMX 요청 시 테이블 행 제거 트리거
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '역할이 삭제되었습니다.',
'action' => 'remove',
]);
}
return response()->json([
'success' => true,
'message' => '역할이 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Services\RoleService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RoleController extends Controller
{
public function __construct(
private readonly RoleService $roleService
) {}
/**
* 역할 목록 (Blade 화면만)
*/
public function index(Request $request): View
{
return view('roles.index');
}
/**
* 역할 생성 화면
*/
public function create(): View
{
$tenantId = session('selected_tenant_id');
// 권한 목록 조회 (현재 테넌트 또는 전체)
$query = \Spatie\Permission\Models\Permission::query()
->where('guard_name', 'web')
->orderBy('name');
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
$permissions = $query->get(['id', 'name']);
return view('roles.create', compact('permissions'));
}
/**
* 역할 수정 화면
*/
public function edit(int $id): View
{
$role = $this->roleService->getRoleById($id);
if (!$role) {
abort(404, '역할을 찾을 수 없습니다.');
}
$tenantId = session('selected_tenant_id');
// 권한 목록 조회 (현재 테넌트 또는 전체)
$query = \Spatie\Permission\Models\Permission::query()
->where('guard_name', 'web')
->orderBy('name');
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
$permissions = $query->get(['id', 'name']);
// 현재 역할이 가진 권한 ID 목록
$rolePermissionIds = $role->permissions->pluck('id')->toArray();
return view('roles.edit', compact('role', 'permissions', 'rolePermissionIds'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreRoleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // 권한 체크는 middleware에서 처리
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tenantId = session('selected_tenant_id');
return [
'name' => [
'required',
'string',
'max:100',
Rule::unique('roles', 'name')
->where('tenant_id', $tenantId)
->where('guard_name', 'web'),
],
'description' => 'nullable|string|max:500',
'permissions' => 'nullable|array',
'permissions.*' => 'exists:permissions,id',
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'name' => '역할 이름',
'description' => '설명',
'permissions' => '권한',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'name.required' => '역할 이름은 필수입니다.',
'name.unique' => '이미 존재하는 역할 이름입니다.',
'name.max' => '역할 이름은 최대 100자까지 입력 가능합니다.',
'description.max' => '설명은 최대 500자까지 입력 가능합니다.',
'permissions.*.exists' => '유효하지 않은 권한이 포함되어 있습니다.',
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRoleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // 권한 체크는 middleware에서 처리
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tenantId = session('selected_tenant_id');
$roleId = $this->route('id'); // URL 파라미터에서 role ID 가져오기
return [
'name' => [
'required',
'string',
'max:100',
Rule::unique('roles', 'name')
->where('tenant_id', $tenantId)
->where('guard_name', 'web')
->ignore($roleId),
],
'description' => 'nullable|string|max:500',
'permissions' => 'nullable|array',
'permissions.*' => 'exists:permissions,id',
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'name' => '역할 이름',
'description' => '설명',
'permissions' => '권한',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'name.required' => '역할 이름은 필수입니다.',
'name.unique' => '이미 존재하는 역할 이름입니다.',
'name.max' => '역할 이름은 최대 100자까지 입력 가능합니다.',
'description.max' => '설명은 최대 500자까지 입력 가능합니다.',
'permissions.*.exists' => '유효하지 않은 권한이 포함되어 있습니다.',
];
}
}

57
app/Models/Role.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Permission\Models\Permission;
class Role extends Model
{
protected $fillable = [
'tenant_id',
'name',
'description',
'guard_name',
];
protected $casts = [
'tenant_id' => 'integer',
];
/**
* 관계: 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
/**
* 관계: 권한 (다대다)
*/
public function permissions(): BelongsToMany
{
return $this->belongsToMany(
Permission::class,
'role_has_permissions',
'role_id',
'permission_id'
);
}
/**
* 관계: 사용자 (다대다)
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'model_has_roles',
'role_id',
'model_id'
)->wherePivot('model_type', User::class);
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Spatie\Permission\Models\Role;
class RoleService
{
/**
* 역할 목록 조회 (페이지네이션)
*/
public function getRoles(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Role::query()
->where('guard_name', 'web')
->withCount('permissions');
// Tenant 필터링 (선택된 경우에만)
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
// 검색 필터
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// 정렬
$sortBy = $filters['sort_by'] ?? 'id';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortBy, $sortDirection);
return $query->paginate($perPage);
}
/**
* 특정 역할 조회
*/
public function getRoleById(int $id): ?Role
{
$tenantId = session('selected_tenant_id');
$query = Role::query()
->where('guard_name', 'web')
->with('permissions');
// Tenant 필터링 (선택된 경우에만)
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
return $query->find($id);
}
/**
* 역할 생성
*/
public function createRole(array $data): Role
{
$tenantId = session('selected_tenant_id');
$role = Role::create([
'tenant_id' => $tenantId,
'guard_name' => 'web',
'name' => $data['name'],
'description' => $data['description'] ?? null,
]);
// 권한 동기화 (있는 경우)
if (!empty($data['permissions'])) {
$role->syncPermissions($data['permissions']);
}
return $role->fresh(['permissions']);
}
/**
* 역할 수정
*/
public function updateRole(int $id, array $data): bool
{
$role = $this->getRoleById($id);
if (!$role) {
return false;
}
$updated = $role->update([
'name' => $data['name'] ?? $role->name,
'description' => $data['description'] ?? $role->description,
]);
// 권한 동기화 (있는 경우)
if (isset($data['permissions'])) {
$role->syncPermissions($data['permissions']);
}
return $updated;
}
/**
* 역할 삭제
*/
public function deleteRole(int $id): bool
{
$role = $this->getRoleById($id);
if (!$role) {
return false;
}
// 권한 연결 해제
$role->permissions()->detach();
// 사용자 연결 해제
$role->users()->detach();
return $role->delete();
}
/**
* 역할 이름 중복 체크
*/
public function isNameExists(string $name, ?int $excludeId = null): bool
{
$tenantId = session('selected_tenant_id');
$query = Role::where('tenant_id', $tenantId)
->where('guard_name', 'web')
->where('name', $name);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
/**
* 활성 역할 목록 (드롭다운용)
*/
public function getActiveRoles(): Collection
{
$tenantId = session('selected_tenant_id');
$query = Role::query()->where('guard_name', 'web');
// Tenant 필터링 (선택된 경우에만)
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
return $query->orderBy('name')->get(['id', 'name', 'description']);
}
/**
* 역할 통계
*/
public function getRoleStats(): array
{
$tenantId = session('selected_tenant_id');
$baseQuery = Role::query()->where('guard_name', 'web');
// Tenant 필터링 (선택된 경우에만)
if ($tenantId) {
$baseQuery->where('tenant_id', $tenantId);
}
return [
'total' => (clone $baseQuery)->count(),
'with_permissions' => (clone $baseQuery)->has('permissions')->count(),
];
}
}

View File

@@ -0,0 +1,113 @@
{{--
공통 페이지네이션 컴포넌트 (HTMX 호환)
사용법:
@include('partials.pagination', [
'paginator' => $tenants,
'target' => '#tenant-table',
'includeForm' => '#filterForm'
])
--}}
<div class="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div class="flex items-center justify-between">
<!-- 모바일 네비게이션 -->
<div class="flex-1 flex justify-between sm:hidden">
@if($paginator->onFirstPage())
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
이전
</span>
@else
<button hx-get="{{ $paginator->previousPageUrl() }}"
hx-target="{{ $target }}"
hx-include="{{ $includeForm ?? '' }}"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
이전
</button>
@endif
@if($paginator->hasMorePages())
<button hx-get="{{ $paginator->nextPageUrl() }}"
hx-target="{{ $target }}"
hx-include="{{ $includeForm ?? '' }}"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
다음
</button>
@else
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
다음
</span>
@endif
</div>
<!-- 데스크톱 네비게이션 -->
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
전체 <span class="font-medium">{{ $paginator->total() }}</span>
<span class="font-medium">{{ $paginator->firstItem() }}</span>
~
<span class="font-medium">{{ $paginator->lastItem() }}</span>
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<!-- 이전 버튼 -->
@if ($paginator->onFirstPage())
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</span>
@else
<button hx-get="{{ $paginator->previousPageUrl() }}"
hx-target="{{ $target }}"
hx-include="{{ $includeForm ?? '' }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</button>
@endif
<!-- 페이지 번호 -->
@foreach ($paginator->getUrlRange(1, $paginator->lastPage()) as $page => $url)
@if ($page == $paginator->currentPage())
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
{{ $page }}
</span>
@else
<button hx-get="{{ $url }}"
hx-target="{{ $target }}"
hx-include="{{ $includeForm ?? '' }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
{{ $page }}
</button>
@endif
@endforeach
<!-- 다음 버튼 -->
@if ($paginator->hasMorePages())
<button hx-get="{{ $paginator->nextPageUrl() }}"
hx-target="{{ $target }}"
hx-include="{{ $includeForm ?? '' }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
</button>
@else
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
</span>
@endif
</nav>
</div>
</div>
</div>
</div>

View File

@@ -42,14 +42,14 @@ class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-
</a>
</li>
<!-- 권한 역할 -->
<!-- 역할 관리 -->
<li>
<a href="#"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100">
<a href="{{ route('roles.index') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 {{ request()->routeIs('roles.*') ? 'bg-primary text-white hover:bg-primary' : '' }}">
<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" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="font-medium">권한 역할</span>
<span class="font-medium">역할 관리</span>
</a>
</li>

View File

@@ -0,0 +1,137 @@
@extends('layouts.app')
@section('title', '역할 생성')
@section('content')
<div class="container mx-auto max-w-4xl">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🔑 역할 생성</h1>
<a href="{{ route('roles.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
<!-- 영역 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="roleForm"
hx-post="/api/admin/roles"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-swap="none">
<!-- 기본 정보 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
역할 이름 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" required maxlength="100"
placeholder="예: 관리자, 일반사용자"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">최대 100자까지 입력 가능합니다.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea name="description" rows="3" maxlength="500"
placeholder="역할에 대한 설명을 입력하세요"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
<p class="text-xs text-gray-500 mt-1">최대 500자까지 입력 가능합니다.</p>
</div>
</div>
</div>
<!-- 권한 선택 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">권한 선택</h2>
@if($permissions->isEmpty())
<div class="text-center py-8 text-gray-500">
<p>선택 가능한 권한이 없습니다.</p>
<p class="text-sm mt-2">테넌트를 선택하거나 권한을 먼저 생성해주세요.</p>
</div>
@else
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
@foreach($permissions as $permission)
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<input type="checkbox" name="permissions[]" value="{{ $permission->id }}"
class="mr-2 h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="font-medium text-gray-800">{{ $permission->name }}</span>
</label>
@endforeach
</div>
<div class="mt-4 flex gap-2">
<button type="button" onclick="selectAllPermissions()"
class="text-sm text-blue-600 hover:text-blue-700 underline">
전체 선택
</button>
<span class="text-gray-400">|</span>
<button type="button" onclick="deselectAllPermissions()"
class="text-sm text-blue-600 hover:text-blue-700 underline">
전체 해제
</button>
</div>
@endif
</div>
<!-- 버튼 영역 -->
<div class="flex justify-end gap-3">
<a href="{{ route('roles.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
생성
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 전체 선택
function selectAllPermissions() {
document.querySelectorAll('input[name="permissions[]"]').forEach(checkbox => {
checkbox.checked = true;
});
}
// 전체 해제
function deselectAllPermissions() {
document.querySelectorAll('input[name="permissions[]"]').forEach(checkbox => {
checkbox.checked = false;
});
}
// HTMX 응답 처리
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'roleForm') {
const response = JSON.parse(event.detail.xhr.response);
if (response.success) {
alert(response.message);
window.location.href = response.redirect;
} else {
alert('오류: ' + (response.message || '역할 생성에 실패했습니다.'));
}
}
});
// 에러 처리
document.body.addEventListener('htmx:responseError', function(event) {
if (event.detail.xhr.status === 422) {
const errors = JSON.parse(event.detail.xhr.response).errors;
let errorMsg = '입력 오류:\n';
for (let field in errors) {
errorMsg += '- ' + errors[field].join('\n') + '\n';
}
alert(errorMsg);
} else {
alert('서버 오류가 발생했습니다.');
}
});
</script>
@endpush

View File

@@ -0,0 +1,163 @@
@extends('layouts.app')
@section('title', '역할 수정')
@section('content')
<div class="container mx-auto max-w-4xl">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🔑 역할 수정</h1>
<a href="{{ route('roles.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
<!-- 영역 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="roleForm"
hx-post="/api/admin/roles/{{ $role->id }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-swap="none">
<input type="hidden" name="_method" value="PUT">
<!-- 기본 정보 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
역할 이름 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" required maxlength="100"
value="{{ old('name', $role->name) }}"
placeholder="예: 관리자, 일반사용자"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">최대 100자까지 입력 가능합니다.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea name="description" rows="3" maxlength="500"
placeholder="역할에 대한 설명을 입력하세요"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description', $role->description) }}</textarea>
<p class="text-xs text-gray-500 mt-1">최대 500자까지 입력 가능합니다.</p>
</div>
</div>
</div>
<!-- 권한 선택 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">권한 선택</h2>
@if($permissions->isEmpty())
<div class="text-center py-8 text-gray-500">
<p>선택 가능한 권한이 없습니다.</p>
<p class="text-sm mt-2">테넌트를 선택하거나 권한을 먼저 생성해주세요.</p>
</div>
@else
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
@foreach($permissions as $permission)
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<input type="checkbox" name="permissions[]" value="{{ $permission->id }}"
{{ in_array($permission->id, $rolePermissionIds) ? 'checked' : '' }}
class="mr-2 h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="font-medium text-gray-800">{{ $permission->name }}</span>
</label>
@endforeach
</div>
<div class="mt-4 flex gap-2">
<button type="button" onclick="selectAllPermissions()"
class="text-sm text-blue-600 hover:text-blue-700 underline">
전체 선택
</button>
<span class="text-gray-400">|</span>
<button type="button" onclick="deselectAllPermissions()"
class="text-sm text-blue-600 hover:text-blue-700 underline">
전체 해제
</button>
</div>
@endif
</div>
<!-- 역할 정보 -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">역할 정보</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-600">생성일:</span>
<span class="font-medium">{{ $role->created_at->format('Y-m-d H:i') }}</span>
</div>
<div>
<span class="text-gray-600">수정일:</span>
<span class="font-medium">{{ $role->updated_at->format('Y-m-d H:i') }}</span>
</div>
<div>
<span class="text-gray-600">현재 권한 :</span>
<span class="font-medium">{{ $role->permissions->count() }}</span>
</div>
<div>
<span class="text-gray-600">역할 ID:</span>
<span class="font-medium">#{{ $role->id }}</span>
</div>
</div>
</div>
<!-- 버튼 영역 -->
<div class="flex justify-end gap-3">
<a href="{{ route('roles.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
수정
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 전체 선택
function selectAllPermissions() {
document.querySelectorAll('input[name="permissions[]"]').forEach(checkbox => {
checkbox.checked = true;
});
}
// 전체 해제
function deselectAllPermissions() {
document.querySelectorAll('input[name="permissions[]"]').forEach(checkbox => {
checkbox.checked = false;
});
}
// HTMX 응답 처리
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'roleForm') {
const response = JSON.parse(event.detail.xhr.response);
if (response.success) {
alert(response.message);
window.location.href = response.redirect;
} else {
alert('오류: ' + (response.message || '역할 수정에 실패했습니다.'));
}
}
});
// 에러 처리
document.body.addEventListener('htmx:responseError', function(event) {
if (event.detail.xhr.status === 422) {
const errors = JSON.parse(event.detail.xhr.response).errors;
let errorMsg = '입력 오류:\n';
for (let field in errors) {
errorMsg += '- ' + errors[field].join('\n') + '\n';
}
alert(errorMsg);
} else {
alert('서버 오류가 발생했습니다.');
}
});
</script>
@endpush

View File

@@ -0,0 +1,84 @@
@extends('layouts.app')
@section('title', '역할 관리')
@section('content')
<!-- Tenant Selector -->
@include('partials.tenant-selector')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mt-6 mb-6">
<h1 class="text-2xl font-bold text-gray-800">🔑 역할 관리</h1>
<a href="{{ route('roles.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>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="role-table"
hx-get="/api/admin/roles"
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>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#role-table', 'filterSubmit');
});
// HTMX 응답 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'role-table') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
}
}
});
// 삭제 확인
window.confirmDelete = function(id, name) {
if (confirm(`"${name}" 역할을 삭제하시겠습니까?`)) {
htmx.ajax('DELETE', `/api/admin/roles/${id}`, {
target: '#role-table',
swap: 'none',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}).then(() => {
htmx.trigger('#role-table', 'filterSubmit');
});
}
};
</script>
@endpush

View File

@@ -0,0 +1,58 @@
<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-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">역할 이름</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">설명</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">권한 </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">생성일</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($roles as $role)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $role->id }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $role->name }}</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-500">{{ $role->description ?? '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
{{ $role->permissions_count ?? 0 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $role->created_at?->format('Y-m-d') ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('roles.edit', $role->id) }}"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
<button onclick="confirmDelete({{ $role->id }}, '{{ $role->name }}')"
class="text-red-600 hover:text-red-900">
삭제
</button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-12 text-center text-gray-500">
등록된 역할이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
@include('partials.pagination', [
'paginator' => $roles,
'target' => '#role-table',
'includeForm' => '#filterForm'
])

View File

@@ -4,64 +4,62 @@
@section('content')
<!-- TENANT INDEX PAGE MARKER - 이것이 보이면 tenants/index.blade.php가 로드된 것입니다 -->
<div class="container mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🏢 테넌트 관리</h1>
<a href="{{ route('tenants.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 테넌트
</a>
</div>
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🏢 테넌트 관리</h1>
<a href="{{ route('tenants.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>
<!-- 상태 필터 -->
<div class="w-48">
<select name="tenant_st_code" 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>
<option value="trial">트라이얼</option>
<option value="active">활성</option>
<option value="suspended">정지</option>
<option value="expired">만료</option>
</select>
</div>
<!-- 삭제된 항목 포함 -->
<div class="w-48">
<select name="trashed" 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>
<option value="with">삭제 포함</option>
<option value="only">삭제만</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>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="tenant-table"
hx-get="/api/admin/tenants"
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 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="tenant_st_code" 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>
<option value="trial">트라이얼</option>
<option value="active">활성</option>
<option value="suspended">정지</option>
<option value="expired">만료</option>
</select>
</div>
<!-- 삭제된 항목 포함 -->
<div class="w-48">
<select name="trashed" 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>
<option value="with">삭제 포함</option>
<option value="only">삭제만</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>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="tenant-table"
hx-get="/api/admin/tenants"
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

View File

@@ -98,51 +98,8 @@ class="text-red-600 hover:text-red-900">
</div>
<!-- 페이지네이션 -->
@if($tenants->hasPages())
<div class="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex-1 flex justify-between sm:hidden">
@if($tenants->onFirstPage())
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
이전
</span>
@else
<button hx-get="{{ $tenants->previousPageUrl() }}"
hx-target="#tenant-table"
hx-include="#filterForm"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
이전
</button>
@endif
@if($tenants->hasMorePages())
<button hx-get="{{ $tenants->nextPageUrl() }}"
hx-target="#tenant-table"
hx-include="#filterForm"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
다음
</button>
@else
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
다음
</span>
@endif
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
전체 <span class="font-medium">{{ $tenants->total() }}</span>
<span class="font-medium">{{ $tenants->firstItem() }}</span>
~
<span class="font-medium">{{ $tenants->lastItem() }}</span>
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{{ $tenants->links() }}
</nav>
</div>
</div>
</div>
</div>
@endif
@include('partials.pagination', [
'paginator' => $tenants,
'target' => '#tenant-table',
'includeForm' => '#filterForm'
])

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\Admin\RoleController;
use App\Http\Controllers\Api\Admin\TenantController;
use Illuminate\Support\Facades\Route;
@@ -30,4 +31,13 @@
Route::post('/{id}/restore', [TenantController::class, 'restore'])->name('restore');
Route::delete('/{id}/force', [TenantController::class, 'forceDestroy'])->name('forceDestroy');
});
// 역할 관리 API
Route::prefix('roles')->name('roles.')->group(function () {
Route::get('/', [RoleController::class, 'index'])->name('index');
Route::post('/', [RoleController::class, 'store'])->name('store');
Route::get('/{id}', [RoleController::class, 'show'])->name('show');
Route::put('/{id}', [RoleController::class, 'update'])->name('update');
Route::delete('/{id}', [RoleController::class, 'destroy'])->name('destroy');
});
});

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\RoleController;
use App\Http\Controllers\TenantController;
use Illuminate\Support\Facades\Route;
@@ -34,6 +35,13 @@
Route::get('/{id}/edit', [TenantController::class, 'edit'])->name('edit');
});
// 역할 관리 (Blade 화면만)
Route::prefix('roles')->name('roles.')->group(function () {
Route::get('/', [RoleController::class, 'index'])->name('index');
Route::get('/create', [RoleController::class, 'create'])->name('create');
Route::get('/{id}/edit', [RoleController::class, 'edit'])->name('edit');
});
// 대시보드
Route::get('/dashboard', function () {
return view('dashboard.index');