withTrashed(); // 테넌트 필터링 if ($tenantId) { // 특정 테넌트 선택 시: 해당 테넌트의 메뉴만 $query->where('tenant_id', $tenantId); } else { // 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만 $query->whereNull('tenant_id'); } // Soft Delete 필터 if (isset($filters['trashed'])) { if ($filters['trashed'] === 'only') { $query->onlyTrashed(); } elseif ($filters['trashed'] === 'with') { $query->withTrashed(); } } // 검색 필터 if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('url', 'like', "%{$search}%"); }); } // 활성 상태 필터 if (isset($filters['is_active'])) { $query->where('is_active', $filters['is_active']); } // 부모 메뉴 필터 (트리 구조) if (isset($filters['parent_id'])) { if ($filters['parent_id'] === 'null') { $query->whereNull('parent_id'); } else { $query->where('parent_id', $filters['parent_id']); } } // 모든 메뉴 가져오기 (트리 구조 정렬을 위해) $allMenus = $query->with(['parent', 'tenant'])->orderBy('sort_order')->orderBy('id')->get(); // 트리 구조로 정렬 후 플랫한 배열로 변환 $flattenedMenus = $this->flattenMenuTree($allMenus); // 수동 페이지네이션 $currentPage = request()->input('page', 1); $offset = ($currentPage - 1) * $perPage; $items = $flattenedMenus->slice($offset, $perPage)->values(); return new \Illuminate\Pagination\LengthAwarePaginator( $items, $flattenedMenus->count(), $perPage, $currentPage, ['path' => request()->url(), 'query' => request()->query()] ); } /** * 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) */ private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): 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 getMenuById(int $id): ?Menu { return Menu::with(['parent', 'children'])->find($id); } /** * 메뉴 트리 구조로 조회 (전체) */ public function getMenuTree(?int $tenantId = null): Collection { $tenantId = $tenantId ?? session('selected_tenant_id'); $query = Menu::query() ->where('is_active', true) ->orderBy('sort_order') ->orderBy('id'); if ($tenantId) { // 특정 테넌트 선택 시: 해당 테넌트의 메뉴만 $query->where('tenant_id', $tenantId); } else { // 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만 $query->whereNull('tenant_id'); } $allMenus = $query->get(); // 부모 메뉴만 필터링하고 자식 메뉴를 재귀적으로 연결 return $allMenus->where('parent_id', null)->map(function ($menu) use ($allMenus) { $menu->children = $this->buildChildren($menu, $allMenus); return $menu; }); } /** * 재귀적으로 자식 메뉴 구성 */ private function buildChildren(Menu $parent, Collection $allMenus): Collection { $children = $allMenus->where('parent_id', $parent->id); return $children->map(function ($child) use ($allMenus) { $child->children = $this->buildChildren($child, $allMenus); return $child; }); } /** * 부모 메뉴 목록 조회 (드롭다운용) */ public function getParentMenus(?int $tenantId = null): Collection { $tenantId = $tenantId ?? session('selected_tenant_id'); $query = Menu::query() ->where('is_active', true) ->orderBy('sort_order') ->orderBy('name'); if ($tenantId) { // 특정 테넌트 선택 시: 해당 테넌트의 메뉴만 $query->where('tenant_id', $tenantId); } else { // 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만 $query->whereNull('tenant_id'); } return $query->get(); } /** * 메뉴 생성 */ public function createMenu(array $data): Menu { $tenantId = session('selected_tenant_id'); // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; // hidden 처리 $data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1'; // is_external 처리 $data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1'; // 생성자 정보 $data['created_by'] = auth()->id(); // 테넌트 정보 if ($tenantId) { $data['tenant_id'] = $tenantId; } // parent_id null 처리 if (empty($data['parent_id'])) { $data['parent_id'] = null; } return Menu::create($data); } /** * 메뉴 수정 */ public function updateMenu(int $id, array $data): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; // hidden 처리 $data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1'; // is_external 처리 $data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1'; // 수정자 정보 $data['updated_by'] = auth()->id(); // parent_id null 처리 if (empty($data['parent_id'])) { $data['parent_id'] = null; } // 자기 자신을 부모로 설정하는 것 방지 if (isset($data['parent_id']) && $data['parent_id'] == $id) { return false; } return $menu->update($data); } /** * 메뉴 삭제 (Soft Delete) */ public function deleteMenu(int $id): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } // 자식 메뉴가 있는 경우 삭제 불가 if ($menu->children()->count() > 0) { return false; } $menu->deleted_by = auth()->id(); $menu->save(); return $menu->delete(); } /** * 메뉴 복원 */ public function restoreMenu(int $id): bool { $menu = Menu::onlyTrashed()->findOrFail($id); return $menu->restore(); } /** * 메뉴 영구 삭제 (슈퍼관리자 전용) */ public function forceDeleteMenu(int $id): bool { $menu = Menu::withTrashed()->findOrFail($id); // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { return false; } return $menu->forceDelete(); } /** * 메뉴 활성 상태 토글 */ public function toggleActive(int $id): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } $menu->is_active = ! $menu->is_active; $menu->updated_by = auth()->id(); return $menu->save(); } /** * 메뉴 숨김 상태 토글 */ public function toggleHidden(int $id): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } $menu->hidden = ! $menu->hidden; $menu->updated_by = auth()->id(); return $menu->save(); } /** * 메뉴 순서 변경 (드래그앤드롭) * 같은 parent_id 내에서만 순서 변경 */ public function reorderMenus(array $items): bool { return \DB::transaction(function () use ($items) { foreach ($items as $item) { Menu::where('id', $item['id']) ->update([ 'sort_order' => $item['sort_order'], 'updated_by' => auth()->id(), ]); } return true; }); } /** * 메뉴 이동 (계층 구조 변경) * - 다른 부모 아래로 이동 가능 * - 하위 메뉴는 자동으로 따라감 * - 순환 참조 방지 */ public function moveMenu(int $menuId, ?int $newParentId, int $sortOrder): array { $menu = Menu::find($menuId); if (! $menu) { return ['success' => false, 'message' => '메뉴를 찾을 수 없습니다.']; } // 순환 참조 방지: 자신의 하위 메뉴로 이동 불가 if ($newParentId !== null && $this->isDescendant($menuId, $newParentId)) { return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.']; } // 자기 자신을 부모로 설정 방지 if ($newParentId === $menuId) { return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.']; } return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) { $oldParentId = $menu->parent_id; // 부모 변경 $menu->parent_id = $newParentId; $menu->sort_order = $sortOrder; $menu->updated_by = auth()->id(); $menu->save(); // 같은 부모의 다른 메뉴들 순서 재정렬 $this->reorderSiblings($newParentId, $menu->id, $sortOrder); // 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기) if ($oldParentId !== $newParentId) { $this->compactSiblings($oldParentId); } return ['success' => true, 'message' => '메뉴가 이동되었습니다.']; }); } /** * 특정 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지) */ private function isDescendant(int $ancestorId, int $menuId): bool { $menu = Menu::find($menuId); while ($menu) { if ($menu->id === $ancestorId) { return true; } $menu = $menu->parent; } return false; } /** * 같은 부모의 형제 메뉴들 순서 재정렬 */ private function reorderSiblings(?int $parentId, int $excludeId, int $insertAt): void { $siblings = Menu::where('parent_id', $parentId) ->where('id', '!=', $excludeId) ->orderBy('sort_order') ->get(); $order = 1; foreach ($siblings as $sibling) { if ($order === $insertAt) { $order++; // 삽입 위치 건너뛰기 } if ($sibling->sort_order !== $order) { $sibling->update(['sort_order' => $order]); } $order++; } } /** * 형제 메뉴들 순서 압축 (빈 자리 채우기) */ private function compactSiblings(?int $parentId): void { $siblings = Menu::where('parent_id', $parentId) ->orderBy('sort_order') ->get(); $order = 1; foreach ($siblings as $sibling) { if ($sibling->sort_order !== $order) { $sibling->update(['sort_order' => $order]); } $order++; } } }