diff --git a/app/Http/Controllers/Api/Admin/MenuController.php b/app/Http/Controllers/Api/Admin/MenuController.php index 9148857b..a1f5d474 100644 --- a/app/Http/Controllers/Api/Admin/MenuController.php +++ b/app/Http/Controllers/Api/Admin/MenuController.php @@ -20,19 +20,36 @@ public function __construct( */ public function index(Request $request): JsonResponse { - $menus = $this->menuService->getMenus( - $request->all(), - $request->integer('per_page', 10) - ); + $tenantId = session('selected_tenant_id'); + $importMode = $request->get('mode') === 'import' && $tenantId; + + if ($importMode) { + // 가져오기 모드: 복사 가능한 글로벌 메뉴 목록 + $menus = $this->menuService->getAvailableGlobalMenus($tenantId); + } else { + // 일반 모드: 현재 범위의 메뉴 목록 + $menus = $this->menuService->getMenus( + $request->all(), + $request->integer('per_page', 10) + ); + } // HTMX 요청인 경우 HTML 반환 if ($request->header('HX-Request')) { - $html = view('menus.partials.table', compact('menus'))->render(); + $html = view('menus.partials.table', compact('menus', 'importMode'))->render(); return response()->json(['html' => $html]); } // 일반 API 요청인 경우 JSON 반환 + if ($importMode) { + return response()->json([ + 'success' => true, + 'data' => $menus, + 'importMode' => true, + ]); + } + return response()->json([ 'success' => true, 'data' => $menus->items(), @@ -350,4 +367,81 @@ public function move(Request $request): JsonResponse ], 500); } } + + /** + * 복사 가능한 글로벌 메뉴 목록 조회 + * (현재 테넌트에 존재하지 않는 글로벌 메뉴만) + */ + public function availableGlobal(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 선택해주세요.', + 'menus' => [], + ], 400); + } + + try { + $menus = $this->menuService->getAvailableGlobalMenus($tenantId); + + return response()->json([ + 'success' => true, + 'menus' => $menus->map(function ($menu) { + return [ + 'id' => $menu->id, + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'depth' => $menu->depth ?? 0, + ]; + })->values(), + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '메뉴 목록 조회에 실패했습니다: '.$e->getMessage(), + 'menus' => [], + ], 500); + } + } + + /** + * 선택한 글로벌 메뉴를 현재 테넌트로 복사 + */ + public function copyFromGlobal(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 선택해주세요.', + 'copied' => 0, + ], 400); + } + + $validated = $request->validate([ + 'menu_ids' => 'required|array|min:1', + 'menu_ids.*' => 'required|integer', + ]); + + try { + $result = $this->menuService->copyFromGlobal($tenantId, $validated['menu_ids']); + + if (! $result['success']) { + return response()->json($result, 400); + } + + return response()->json($result); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '메뉴 복사에 실패했습니다: '.$e->getMessage(), + 'copied' => 0, + ], 500); + } + } } diff --git a/app/Models/Commons/Menu.php b/app/Models/Commons/Menu.php index 5e93534a..08c4aa77 100644 --- a/app/Models/Commons/Menu.php +++ b/app/Models/Commons/Menu.php @@ -16,8 +16,8 @@ class Menu extends Model use BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ - 'tenant_id', 'parent_id', 'name', 'url', 'is_active', 'sort_order', - 'hidden', 'is_external', 'external_url', 'icon', + 'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order', + 'hidden', 'is_external', 'external_url', 'icon', 'is_customized', 'created_by', 'updated_by', 'deleted_by', ]; @@ -28,6 +28,21 @@ class Menu extends Model 'deleted_at', ]; + protected $casts = [ + 'is_active' => 'boolean', + 'hidden' => 'boolean', + 'is_external' => 'boolean', + 'is_customized' => 'boolean', + ]; + + /** + * 동기화 비교 대상 필드 + */ + public static function getSyncFields(): array + { + return ['name', 'url', 'icon', 'sort_order', 'is_active', 'hidden', 'is_external', 'external_url']; + } + public function parent() { return $this->belongsTo(Menu::class, 'parent_id'); @@ -43,6 +58,79 @@ public function tenant() return $this->belongsTo(\App\Models\Tenants\Tenant::class, 'tenant_id'); } + /** + * 원본 글로벌 메뉴 (복제된 메뉴인 경우) + */ + public function globalMenu() + { + return $this->belongsTo(Menu::class, 'global_menu_id'); + } + + /** + * 이 글로벌 메뉴에서 복제된 테넌트 메뉴들 + */ + public function tenantMenus() + { + return $this->hasMany(Menu::class, 'global_menu_id'); + } + + /** + * 글로벌 메뉴인지 확인 + */ + public function isGlobal(): bool + { + return is_null($this->tenant_id); + } + + /** + * 글로벌 메뉴에서 복제된 메뉴인지 확인 + */ + public function isClonedFromGlobal(): bool + { + return ! is_null($this->global_menu_id); + } + + /** + * 테넌트가 커스터마이징 했는지 확인 + */ + public function isCustomized(): bool + { + return (bool) $this->is_customized; + } + + /** + * 글로벌 메뉴 스코프 (tenant_id가 NULL인 것) + */ + public function scopeGlobal($query) + { + return $query->withoutGlobalScope(TenantScope::class) + ->whereNull('tenant_id'); + } + + /** + * 활성 메뉴 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 표시되는 메뉴 스코프 (hidden=false) + */ + public function scopeVisible($query) + { + return $query->where('hidden', false); + } + + /** + * 최상위 메뉴 스코프 (parent_id가 NULL인 것) + */ + public function scopeRoots($query) + { + return $query->whereNull('parent_id'); + } + /** * 공유(NULL) + 현재 테넌트 모두 포함해서 조회 * (SoftDeletes 글로벌 스코프는 그대로 유지) diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index 6b7cd164..f0539771 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -3,8 +3,10 @@ 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 { @@ -440,4 +442,114 @@ private function compactSiblings(?int $parentId): void $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, + ]; + }); + } } diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index ca9868c0..85cc9787 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -7,18 +7,48 @@

메뉴 관리

-

+

드래그: 순서 변경 | → 오른쪽: 하위로 이동 | ← 왼쪽: 상위로 이동

- - + 새 메뉴 - +
+ @if(session('selected_tenant_id')) + +
+ + +
+ + + @endif + + + 새 메뉴 + +
+ + +
cb.checked = checkbox.checked); + updateImportButtonState(); + }; + + // 선택된 글로벌 메뉴 가져오기 + window.importSelectedMenus = function() { + const checkboxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked'); + const menuIds = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (menuIds.length === 0) { + alert('가져올 메뉴를 선택해주세요.'); + return; + } + + if (!confirm(`선택한 ${menuIds.length}개 메뉴를 가져오시겠습니까?`)) { + return; + } + + fetch('/api/admin/menus/copy-from-global', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + }, + body: JSON.stringify({ menu_ids: menuIds }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(`${data.copied}개 메뉴가 복사되었습니다.`); + htmx.trigger('#menu-table', 'filterSubmit'); + } else { + alert('가져오기 실패: ' + (data.message || '')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('가져오기 중 오류 발생'); + }); + }; + @endpush diff --git a/resources/views/menus/partials/table.blade.php b/resources/views/menus/partials/table.blade.php index 22feaa35..86530271 100644 --- a/resources/views/menus/partials/table.blade.php +++ b/resources/views/menus/partials/table.blade.php @@ -2,8 +2,18 @@ + {{-- 체크박스 (가져오기 모드일 때만 표시) --}} + @if($importMode ?? false) + + @else - + @endif + @@ -19,7 +29,15 @@ data-parent-id="{{ $menu->parent_id ?? '' }}" data-sort-order="{{ $menu->sort_order ?? 0 }}" data-depth="{{ $menu->depth ?? 0 }}"> - {{-- 드래그 핸들 --}} + {{-- 체크박스 또는 드래그 핸들 --}} + @if($importMode ?? false) + + @else + @endif
+ + IDNo. 메뉴명 URL 정렬 + + @if(!$menu->deleted_at) @@ -29,8 +47,9 @@ @endif - {{ $menu->id }} + {{ $loop->iteration }}
@@ -84,7 +103,14 @@ class="toggle-btn flex items-center text-blue-500 hover:text-blue-700 focus:outl {{ $menu->sort_order ?? 0 }}
- @if(!$menu->deleted_at) + @if($importMode ?? false) + {{-- 가져오기 모드: 읽기 전용 상태 표시 --}} + @if($menu->is_active) + 활성 + @else + 비활성 + @endif + @elseif(!$menu->deleted_at) - @if(!$menu->deleted_at) + @if($importMode ?? false) + {{-- 가져오기 모드: 읽기 전용 상태 표시 --}} + @if($menu->hidden) + 숨김 + @else + 표시 + @endif + @elseif(!$menu->deleted_at) - @if($menu->deleted_at) + @if($importMode ?? false) + + + 글로벌 + + @elseif($menu->deleted_at)
- + +@if(!($importMode ?? false) && method_exists($menus, 'hasPages')) @include('partials.pagination', [ 'paginator' => $menus, 'target' => '#menu-table', 'includeForm' => '#filterForm' ]) +@endif \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 4105d12c..6a062083 100644 --- a/routes/api.php +++ b/routes/api.php @@ -112,6 +112,8 @@ Route::get('/tree', [MenuController::class, 'tree'])->name('tree'); Route::post('/reorder', [MenuController::class, 'reorder'])->name('reorder'); Route::post('/move', [MenuController::class, 'move'])->name('move'); + Route::get('/available-global', [MenuController::class, 'availableGlobal'])->name('availableGlobal'); + Route::post('/copy-from-global', [MenuController::class, 'copyFromGlobal'])->name('copyFromGlobal'); // 동적 경로는 나중에 정의 Route::get('/', [MenuController::class, 'index'])->name('index');