- MenuService.reorderMenus() 메서드 추가 - MenuController.reorder() API 엔드포인트 추가 - POST /api/admin/menus/reorder 라우트 추가 - SortableJS 기반 드래그 앤 드롭 UI 구현 - 같은 부모 메뉴 내에서만 순서 변경 가능 (계층 구조 유지)
344 lines
9.4 KiB
PHP
344 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Commons\Menu;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class MenuService
|
|
{
|
|
/**
|
|
* 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬
|
|
*/
|
|
public function getMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
$query = Menu::query()->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;
|
|
});
|
|
}
|
|
}
|