diff --git a/app/Http/Controllers/Api/Admin/TenantController.php b/app/Http/Controllers/Api/Admin/TenantController.php index 30b058d3..9d94f1ab 100644 --- a/app/Http/Controllers/Api/Admin/TenantController.php +++ b/app/Http/Controllers/Api/Admin/TenantController.php @@ -205,4 +205,111 @@ public function stats(Request $request): JsonResponse 'data' => $stats, ]); } + + /** + * 테넌트 모달 정보 조회 + */ + public function modal(Request $request, int $id): JsonResponse + { + $tenant = $this->tenantService->getTenantForModal($id); + + if (! $tenant) { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 찾을 수 없습니다.', + ], 404); + } + + $html = view('tenants.partials.modal-info', compact('tenant'))->render(); + + return response()->json([ + 'success' => true, + 'html' => $html, + ]); + } + + /** + * 테넌트 사용자 탭 데이터 + */ + public function users(Request $request, int $id): JsonResponse + { + $users = $this->tenantService->getTenantUsers($id); + + $html = view('tenants.partials.modal-users', compact('users'))->render(); + + return response()->json([ + 'success' => true, + 'html' => $html, + ]); + } + + /** + * 테넌트 부서 탭 데이터 + */ + public function departments(Request $request, int $id): JsonResponse + { + $departments = $this->tenantService->getTenantDepartments($id); + + $html = view('tenants.partials.modal-departments', compact('departments'))->render(); + + return response()->json([ + 'success' => true, + 'html' => $html, + ]); + } + + /** + * 테넌트 역할 탭 데이터 + */ + public function roles(Request $request, int $id): JsonResponse + { + $roles = $this->tenantService->getTenantRoles($id); + + $html = view('tenants.partials.modal-roles', compact('roles'))->render(); + + return response()->json([ + 'success' => true, + 'html' => $html, + ]); + } + + /** + * 테넌트 메뉴 탭 데이터 + */ + public function menus(Request $request, int $id): JsonResponse + { + $menus = $this->tenantService->getTenantMenus($id); + + $html = view('tenants.partials.modal-menus', compact('menus'))->render(); + + return response()->json([ + 'success' => true, + 'html' => $html, + ]); + } + + /** + * 테넌트 구독 정보 탭 데이터 + */ + public function subscription(Request $request, int $id): JsonResponse + { + $tenant = $this->tenantService->getTenantForModal($id); + + if (! $tenant) { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 찾을 수 없습니다.', + ], 404); + } + + $subscription = $this->tenantService->getTenantSubscription($id); + $usage = $this->tenantService->getTenantUsage($id); + + $html = view('tenants.partials.modal-subscription', compact('tenant', 'subscription', 'usage'))->render(); + + return response()->json([ + 'success' => true, + 'html' => $html, + ]); + } } diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php index adf823c5..db04a72a 100644 --- a/app/Models/Tenants/Tenant.php +++ b/app/Models/Tenants/Tenant.php @@ -29,6 +29,8 @@ class Tenant extends Model 'fax', // 구독 정보 'tenant_st_code', + 'tenant_type', + 'template_id', 'billing_tp_code', 'max_users', 'trial_ends_at', @@ -36,6 +38,8 @@ class Tenant extends Model 'last_paid_at', // 관리 메모 'admin_memo', + // 삭제 정보 + 'deleted_by', ]; protected $casts = [ @@ -88,6 +92,14 @@ public function roles(): HasMany return $this->hasMany(\App\Models\Permissions\Role::class, 'tenant_id'); } + /** + * 관계: 삭제한 사용자 + */ + public function deletedByUser(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class, 'deleted_by'); + } + /** * 상태 배지 색상 (Blade 뷰에서 사용) */ diff --git a/app/Services/TenantService.php b/app/Services/TenantService.php index 6650b56e..d67f8505 100644 --- a/app/Services/TenantService.php +++ b/app/Services/TenantService.php @@ -5,6 +5,7 @@ use App\Models\Tenants\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; class TenantService { @@ -88,6 +89,10 @@ public function deleteTenant(int $id): bool { $tenant = Tenant::findOrFail($id); + // 삭제자 기록 + $tenant->deleted_by = auth()->id(); + $tenant->save(); + return $tenant->delete(); } @@ -98,6 +103,9 @@ public function restoreTenant(int $id): bool { $tenant = Tenant::onlyTrashed()->findOrFail($id); + // 삭제 정보 초기화 + $tenant->deleted_by = null; + return $tenant->restore(); } @@ -156,4 +164,195 @@ public function getTenantStats(): array 'trashed' => Tenant::onlyTrashed()->count(), ]; } + + /** + * 모달용 테넌트 상세 정보 조회 + */ + public function getTenantForModal(int $id): ?Tenant + { + return Tenant::query() + ->with('deletedByUser') + ->withCount(['users', 'departments', 'menus', 'roles']) + ->withTrashed() + ->find($id); + } + + /** + * 테넌트 소속 사용자 목록 조회 + */ + public function getTenantUsers(int $tenantId): Collection + { + $tenant = Tenant::find($tenantId); + if (! $tenant) { + return collect(); + } + + // 사용자와 함께 역할/부서 정보를 가져옴 + return $tenant->users() + ->with(['userRoles' => function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId)->with('role'); + }, 'departmentUsers' => function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId)->with('department'); + }]) + ->orderBy('name') + ->get() + ->map(function ($user) { + // 역할과 부서를 플랫하게 매핑 + $user->roles = $user->userRoles->pluck('role')->filter(); + $user->department = $user->departmentUsers->first()?->department; + + return $user; + }); + } + + /** + * 테넌트 부서 목록 조회 (계층 구조) + */ + public function getTenantDepartments(int $tenantId): Collection + { + // Department 모델의 users() 관계가 잘못된 네임스페이스를 참조하므로 직접 카운트 + $departments = \App\Models\Department::query() + ->where('tenant_id', $tenantId) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + // 부서별 사용자 수 직접 카운트 + $userCounts = DB::table('department_user') + ->whereIn('department_id', $departments->pluck('id')) + ->selectRaw('department_id, count(*) as cnt') + ->groupBy('department_id') + ->pluck('cnt', 'department_id'); + + return $departments->map(function ($dept) use ($userCounts, $departments) { + // 사용자 수 설정 + $dept->users_count = $userCounts[$dept->id] ?? 0; + + // 계층 깊이 계산 + $depth = 0; + $parentId = $dept->parent_id; + while ($parentId) { + $depth++; + $parent = $departments->firstWhere('id', $parentId); + $parentId = $parent?->parent_id; + } + $dept->depth = $depth; + + // 상위 부서 이름 + $dept->parent = $departments->firstWhere('id', $dept->parent_id); + + return $dept; + }); + } + + /** + * 테넌트 역할 목록 조회 + */ + public function getTenantRoles(int $tenantId): Collection + { + // Role 모델의 userRoles() 관계가 잘못된 네임스페이스를 참조하므로 직접 카운트 + $roles = \App\Models\Role::query() + ->where('tenant_id', $tenantId) + ->withCount('permissions') + ->with('permissions') + ->orderBy('name') + ->get(); + + // 역할별 사용자 수 직접 카운트 + $userCounts = DB::table('user_roles') + ->whereIn('role_id', $roles->pluck('id')) + ->selectRaw('role_id, count(*) as cnt') + ->groupBy('role_id') + ->pluck('cnt', 'role_id'); + + return $roles->map(function ($role) use ($userCounts) { + $role->users_count = $userCounts[$role->id] ?? 0; + + return $role; + }); + } + + /** + * 테넌트 메뉴 목록 조회 (계층 구조 - 트리 정렬) + */ + public function getTenantMenus(int $tenantId): \Illuminate\Support\Collection + { + $menus = \App\Models\Commons\Menu::query() + ->where('tenant_id', $tenantId) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + // 트리 구조로 정렬 후 플랫한 배열로 변환 + return $this->flattenMenuTree($menus); + } + + /** + * 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) + */ + private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): \Illuminate\Support\Collection + { + $result = collect(); + + $filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order'); + + foreach ($filteredMenus as $menu) { + $menu->depth = $depth; + + // 자식 메뉴 존재 여부 확인 + $menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0; + + $result->push($menu); + + // 자식 메뉴 재귀적으로 추가 + $children = $this->flattenMenuTree($menus, $menu->id, $depth + 1); + $result = $result->merge($children); + } + + return $result; + } + + /** + * 테넌트 구독 정보 조회 + */ + public function getTenantSubscription(int $tenantId): object + { + $tenant = Tenant::find($tenantId); + + // 기본 구독 정보 (실제 구현 시 별도 테이블에서 조회) + return (object) [ + 'plan_name' => $tenant?->subscription_plan ?? '기본 플랜', + 'status' => $tenant?->subscription_status ?? 'active', + 'started_at' => $tenant?->created_at?->format('Y-m-d'), + 'expires_at' => $tenant?->subscription_expires_at ?? null, + 'next_billing_date' => null, + 'max_users' => $tenant?->max_users ?? null, + 'max_storage' => $tenant?->max_storage ?? null, + 'max_storage_mb' => null, + 'has_api_access' => true, + 'has_advanced_reports' => false, + 'features' => [ + ['name' => '기본 기능', 'enabled' => true], + ['name' => '사용자 관리', 'enabled' => true], + ['name' => '부서 관리', 'enabled' => true], + ['name' => '역할 관리', 'enabled' => true], + ['name' => 'API 접근', 'enabled' => true], + ['name' => '고급 보고서', 'enabled' => false], + ], + ]; + } + + /** + * 테넌트 사용량 정보 조회 + */ + public function getTenantUsage(int $tenantId): object + { + $tenant = Tenant::withCount('users')->find($tenantId); + + return (object) [ + 'users_count' => $tenant?->users_count ?? 0, + 'storage_used' => '0 MB', + 'storage_used_mb' => 0, + ]; + } } diff --git a/public/js/context-menu.js b/public/js/context-menu.js new file mode 100644 index 00000000..5cf82676 --- /dev/null +++ b/public/js/context-menu.js @@ -0,0 +1,139 @@ +/** + * 컨텍스트 메뉴 (우클릭 메뉴) + * 테넌트명, 사용자명 등에서 우클릭 시 해당 위치에 메뉴 표시 + */ + +class ContextMenu { + constructor() { + this.menuElement = null; + this.currentTarget = null; + this.init(); + } + + init() { + // 컨텍스트 메뉴 요소 찾기 + this.menuElement = document.getElementById('context-menu'); + if (!this.menuElement) return; + + // 우클릭 이벤트 등록 (이벤트 위임) + document.addEventListener('contextmenu', (e) => this.handleContextMenu(e)); + + // 클릭 시 메뉴 닫기 + document.addEventListener('click', () => this.hide()); + + // ESC 키로 메뉴 닫기 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.hide(); + }); + + // 스크롤 시 메뉴 닫기 + document.addEventListener('scroll', () => this.hide(), true); + } + + handleContextMenu(e) { + const trigger = e.target.closest('[data-context-menu]'); + if (!trigger) return; + + e.preventDefault(); + + const menuType = trigger.dataset.contextMenu; + const entityId = trigger.dataset.entityId; + const entityName = trigger.dataset.entityName; + + this.currentTarget = { + type: menuType, + id: entityId, + name: entityName, + element: trigger + }; + + this.show(e.clientX, e.clientY, menuType); + } + + show(x, y, menuType) { + if (!this.menuElement) return; + + // 메뉴 타입에 따라 항목 표시/숨김 + this.updateMenuItems(menuType); + + // 메뉴 표시 + this.menuElement.classList.remove('hidden'); + + // 위치 조정 (화면 밖으로 나가지 않도록) + const menuRect = this.menuElement.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let posX = x; + let posY = y; + + if (x + menuRect.width > viewportWidth) { + posX = viewportWidth - menuRect.width - 10; + } + + if (y + menuRect.height > viewportHeight) { + posY = viewportHeight - menuRect.height - 10; + } + + this.menuElement.style.left = `${posX}px`; + this.menuElement.style.top = `${posY}px`; + } + + hide() { + if (this.menuElement) { + this.menuElement.classList.add('hidden'); + } + this.currentTarget = null; + } + + updateMenuItems(menuType) { + // 모든 메뉴 항목 숨기기 + const allItems = this.menuElement.querySelectorAll('[data-menu-for]'); + allItems.forEach(item => { + const forTypes = item.dataset.menuFor.split(','); + if (forTypes.includes(menuType) || forTypes.includes('all')) { + item.classList.remove('hidden'); + } else { + item.classList.add('hidden'); + } + }); + } + + // 메뉴 항목 클릭 핸들러 + handleMenuAction(action) { + if (!this.currentTarget) return; + + const { type, id, name } = this.currentTarget; + + switch (action) { + case 'view-tenant': + if (typeof TenantModal !== 'undefined') { + TenantModal.open(id); + } + break; + case 'edit-tenant': + window.location.href = `/tenants/${id}/edit`; + break; + case 'view-user': + if (typeof UserModal !== 'undefined') { + UserModal.open(id); + } + break; + case 'edit-user': + window.location.href = `/users/${id}/edit`; + break; + default: + console.log('Unknown action:', action, { type, id, name }); + } + + this.hide(); + } +} + +// 전역 인스턴스 생성 +const contextMenu = new ContextMenu(); + +// 전역 함수로 노출 (onclick에서 사용) +function handleContextMenuAction(action) { + contextMenu.handleMenuAction(action); +} \ No newline at end of file diff --git a/public/js/tenant-modal.js b/public/js/tenant-modal.js new file mode 100644 index 00000000..8cabbaee --- /dev/null +++ b/public/js/tenant-modal.js @@ -0,0 +1,242 @@ +/** + * 테넌트 정보 모달 + * 테넌트 상세 정보를 팝업으로 표시하고 탭으로 관련 정보 관리 + */ + +const TenantModal = { + modalElement: null, + currentTenantId: null, + currentTab: 'info', + + init() { + this.modalElement = document.getElementById('tenant-modal'); + if (!this.modalElement) return; + + // ESC 키로 모달 닫기 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.isOpen()) { + this.close(); + } + }); + + // 배경 클릭 시 모달 닫기 + this.modalElement.addEventListener('click', (e) => { + if (e.target === this.modalElement) { + this.close(); + } + }); + }, + + isOpen() { + return this.modalElement && !this.modalElement.classList.contains('hidden'); + }, + + async open(tenantId) { + if (!this.modalElement) { + console.error('Tenant modal element not found'); + return; + } + + this.currentTenantId = tenantId; + this.currentTab = 'subscription'; + + // 모달 표시 + this.modalElement.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + + // 로딩 표시 + this.showLoading(); + + // 테넌트 정보 로드 + await this.loadTenantInfo(); + }, + + close() { + if (this.modalElement) { + this.modalElement.classList.add('hidden'); + document.body.style.overflow = ''; + } + this.currentTenantId = null; + }, + + showLoading() { + const content = document.getElementById('tenant-modal-content'); + if (content) { + content.innerHTML = ` +
${message}
+ +