- PULL 방식 메뉴 가져오기 (테넌트가 글로벌에서 선택적으로 가져옴) - 모드 전환 UI (내 메뉴 / 글로벌에서 가져오기) - 체크박스 선택으로 다중 메뉴 가져오기 지원 - 가져오기 모드에서 읽기 전용 상태 배지 표시 - hidden input으로 HTMX mode 파라미터 전달 수정
556 lines
17 KiB
PHP
556 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Commons\Menu;
|
|
use App\Models\Tenants\Tenant;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 메뉴 이동 (계층 구조 변경)
|
|
* - 다른 부모 아래로 이동 가능
|
|
* - 하위 메뉴는 자동으로 따라감
|
|
* - 순환 참조 방지
|
|
*/
|
|
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++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 복사 가능한 글로벌 메뉴 목록 조회
|
|
* (현재 테넌트에 존재하지 않는 글로벌 메뉴만)
|
|
*/
|
|
public function getAvailableGlobalMenus(int $tenantId): Collection
|
|
{
|
|
// 글로벌 메뉴 전체 조회
|
|
$globalMenus = Menu::whereNull('tenant_id')
|
|
->where('is_active', true)
|
|
->orderBy('parent_id')
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
// 현재 테넌트에 이미 복사된 메뉴의 global_menu_id 목록
|
|
$existingGlobalIds = Menu::where('tenant_id', $tenantId)
|
|
->whereNotNull('global_menu_id')
|
|
->pluck('global_menu_id')
|
|
->toArray();
|
|
|
|
// 현재 테넌트에 없는 글로벌 메뉴만 필터링
|
|
$availableMenus = $globalMenus->filter(function ($menu) use ($existingGlobalIds) {
|
|
return ! in_array($menu->id, $existingGlobalIds);
|
|
});
|
|
|
|
// 트리 구조로 정렬 (depth 정보 포함)
|
|
return $this->flattenMenuTree($availableMenus);
|
|
}
|
|
|
|
/**
|
|
* 선택한 글로벌 메뉴를 현재 테넌트로 복사
|
|
*/
|
|
public function copyFromGlobal(int $tenantId, array $menuIds): array
|
|
{
|
|
if (empty($menuIds)) {
|
|
return ['success' => false, 'message' => '복사할 메뉴를 선택해주세요.', 'copied' => 0];
|
|
}
|
|
|
|
// 선택된 글로벌 메뉴 조회
|
|
$globalMenus = Menu::whereNull('tenant_id')
|
|
->whereIn('id', $menuIds)
|
|
->orderBy('parent_id') // 부모 먼저 복사하기 위해
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
if ($globalMenus->isEmpty()) {
|
|
return ['success' => false, 'message' => '유효한 글로벌 메뉴가 없습니다.', 'copied' => 0];
|
|
}
|
|
|
|
$copied = 0;
|
|
|
|
return DB::transaction(function () use ($globalMenus, $tenantId, &$copied) {
|
|
// global_menu_id → 새로 생성된 tenant menu id 매핑
|
|
$idMapping = [];
|
|
|
|
foreach ($globalMenus as $globalMenu) {
|
|
// 이미 복사된 메뉴인지 확인
|
|
$exists = Menu::where('tenant_id', $tenantId)
|
|
->where('global_menu_id', $globalMenu->id)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
continue;
|
|
}
|
|
|
|
// 부모 메뉴 매핑 (글로벌 → 테넌트)
|
|
$newParentId = null;
|
|
if ($globalMenu->parent_id) {
|
|
// 이번 복사에서 생성된 부모가 있는지 확인
|
|
if (isset($idMapping[$globalMenu->parent_id])) {
|
|
$newParentId = $idMapping[$globalMenu->parent_id];
|
|
} else {
|
|
// 기존에 복사된 부모 메뉴가 있는지 확인
|
|
$parentTenantMenu = Menu::where('tenant_id', $tenantId)
|
|
->where('global_menu_id', $globalMenu->parent_id)
|
|
->first();
|
|
$newParentId = $parentTenantMenu?->id;
|
|
}
|
|
}
|
|
|
|
// 새 테넌트 메뉴 생성
|
|
$newMenu = Menu::create([
|
|
'tenant_id' => $tenantId,
|
|
'parent_id' => $newParentId,
|
|
'global_menu_id' => $globalMenu->id,
|
|
'name' => $globalMenu->name,
|
|
'url' => $globalMenu->url,
|
|
'icon' => $globalMenu->icon,
|
|
'sort_order' => $globalMenu->sort_order,
|
|
'is_active' => $globalMenu->is_active,
|
|
'hidden' => $globalMenu->hidden,
|
|
'is_external' => $globalMenu->is_external,
|
|
'external_url' => $globalMenu->external_url,
|
|
'is_customized' => false,
|
|
'created_by' => auth()->id(),
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// ID 매핑 저장 (자식 메뉴 복사 시 참조용)
|
|
$idMapping[$globalMenu->id] = $newMenu->id;
|
|
$copied++;
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "{$copied}개 메뉴가 복사되었습니다.",
|
|
'copied' => $copied,
|
|
];
|
|
});
|
|
}
|
|
}
|