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:
111
CURRENT_WORKS.md
111
CURRENT_WORKS.md
@@ -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`
|
||||
- ⏳ 커밋 대기 중 (사용자 확인 후 커밋 예정)
|
||||
143
app/Http/Controllers/Api/Admin/RoleController.php
Normal file
143
app/Http/Controllers/Api/Admin/RoleController.php
Normal 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' => '역할이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
app/Http/Controllers/RoleController.php
Normal file
73
app/Http/Controllers/RoleController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
65
app/Http/Requests/StoreRoleRequest.php
Normal file
65
app/Http/Requests/StoreRoleRequest.php
Normal 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' => '유효하지 않은 권한이 포함되어 있습니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
67
app/Http/Requests/UpdateRoleRequest.php
Normal file
67
app/Http/Requests/UpdateRoleRequest.php
Normal 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
57
app/Models/Role.php
Normal 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);
|
||||
}
|
||||
}
|
||||
183
app/Services/RoleService.php
Normal file
183
app/Services/RoleService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
113
resources/views/partials/pagination.blade.php
Normal file
113
resources/views/partials/pagination.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
137
resources/views/roles/create.blade.php
Normal file
137
resources/views/roles/create.blade.php
Normal 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
|
||||
163
resources/views/roles/edit.blade.php
Normal file
163
resources/views/roles/edit.blade.php
Normal 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
|
||||
84
resources/views/roles/index.blade.php
Normal file
84
resources/views/roles/index.blade.php
Normal 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
|
||||
58
resources/views/roles/partials/table.blade.php
Normal file
58
resources/views/roles/partials/table.blade.php
Normal 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'
|
||||
])
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user