diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index dfed07e8..95951170 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -310,4 +310,113 @@ ### 기술적 결정: - **모든 페이지 공통**: `@include('partials.tenant-selector')` 사용 ### Git 커밋: -- ✅ `661c5ad` "feat: 테넌트 선택 기능 구현" \ No newline at end of file +- ✅ `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` +- ⏳ 커밋 대기 중 (사용자 확인 후 커밋 예정) \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/RoleController.php b/app/Http/Controllers/Api/Admin/RoleController.php new file mode 100644 index 00000000..31316974 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/RoleController.php @@ -0,0 +1,143 @@ +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' => '역할이 삭제되었습니다.', + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php new file mode 100644 index 00000000..9cef3c99 --- /dev/null +++ b/app/Http/Controllers/RoleController.php @@ -0,0 +1,73 @@ +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')); + } +} \ No newline at end of file diff --git a/app/Http/Requests/StoreRoleRequest.php b/app/Http/Requests/StoreRoleRequest.php new file mode 100644 index 00000000..f0545d92 --- /dev/null +++ b/app/Http/Requests/StoreRoleRequest.php @@ -0,0 +1,65 @@ + [ + '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' => '유효하지 않은 권한이 포함되어 있습니다.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/UpdateRoleRequest.php b/app/Http/Requests/UpdateRoleRequest.php new file mode 100644 index 00000000..a61ed5e9 --- /dev/null +++ b/app/Http/Requests/UpdateRoleRequest.php @@ -0,0 +1,67 @@ +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' => '유효하지 않은 권한이 포함되어 있습니다.', + ]; + } +} \ No newline at end of file diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 00000000..8d96da8c --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,57 @@ + '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); + } +} \ No newline at end of file diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php new file mode 100644 index 00000000..35c4e9c2 --- /dev/null +++ b/app/Services/RoleService.php @@ -0,0 +1,183 @@ +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(), + ]; + } +} \ No newline at end of file diff --git a/resources/views/partials/pagination.blade.php b/resources/views/partials/pagination.blade.php new file mode 100644 index 00000000..a77fabc0 --- /dev/null +++ b/resources/views/partials/pagination.blade.php @@ -0,0 +1,113 @@ +{{-- + 공통 페이지네이션 컴포넌트 (HTMX 호환) + + 사용법: + @include('partials.pagination', [ + 'paginator' => $tenants, + 'target' => '#tenant-table', + 'includeForm' => '#filterForm' + ]) +--}} + +
+ 전체 {{ $paginator->total() }}개 중 + {{ $paginator->firstItem() }} + ~ + {{ $paginator->lastItem() }} +
+| ID | +역할 이름 | +설명 | +권한 수 | +생성일 | +액션 | +
|---|---|---|---|---|---|
| + {{ $role->id }} + | +
+ {{ $role->name }}
+ |
+
+ {{ $role->description ?? '-' }}
+ |
+ + {{ $role->permissions_count ?? 0 }} + | ++ {{ $role->created_at?->format('Y-m-d') ?? '-' }} + | ++ + 수정 + + + | +
| + 등록된 역할이 없습니다. + | +|||||
- 전체 {{ $tenants->total() }}개 중 - {{ $tenants->firstItem() }} - ~ - {{ $tenants->lastItem() }} -
-