diff --git a/app/Http/Controllers/MenuSyncController.php b/app/Http/Controllers/MenuSyncController.php index f54e2cfb..2b17069a 100644 --- a/app/Http/Controllers/MenuSyncController.php +++ b/app/Http/Controllers/MenuSyncController.php @@ -85,6 +85,7 @@ public function index(Request $request): View|Response 'diff' => $diff, 'localTenantName' => $localTenant?->company_name ?? '알 수 없음', 'remoteTenantName' => $this->remoteTenantName, + 'hasOrderSnapshot' => session()->has("menu_order_snapshot_{$selectedEnv}"), ]); } @@ -470,6 +471,213 @@ private function filterMenusByName(array $menus, array $names, ?string $parentNa return $result; } + /** + * 순서 동기화 Push (로컬 순서 → 원격 서버) + */ + public function pushOrder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'env' => 'required|string|in:dev,prod', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + try { + // 1. 원격 서버 현재 순서 스냅샷 저장 (되돌리기용) + $remoteMenus = $this->fetchRemoteMenus($env); + $snapshot = $this->buildOrderMap($remoteMenus); + session()->put("menu_order_snapshot_{$validated['env']}", $snapshot); + + // 2. 로컬 메뉴 트리에서 순서 매핑 생성 + $localMenus = $this->getMenuTree(); + $orderMap = $this->buildOrderMap($localMenus); + + // 3. 원격 서버에 순서 업데이트 전송 + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [ + 'menus' => $orderMap, + 'tenant_id' => $this->getTenantId(), + ]); + + if ($response->successful()) { + return response()->json([ + 'success' => true, + 'message' => $response->json('message', '순서 동기화 완료'), + 'hasSnapshot' => true, + ]); + } + + // 실패 시 스냅샷 삭제 + session()->forget("menu_order_snapshot_{$validated['env']}"); + + return response()->json([ + 'error' => $response->json('error', '원격 서버 오류'), + ], $response->status()); + } catch (\Exception $e) { + session()->forget("menu_order_snapshot_{$validated['env']}"); + + return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500); + } + } + + /** + * 순서 동기화 되돌리기 + */ + public function undoOrder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'env' => 'required|string|in:dev,prod', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + $snapshot = session("menu_order_snapshot_{$validated['env']}"); + if (empty($snapshot)) { + return response()->json(['error' => '되돌릴 스냅샷이 없습니다.'], 400); + } + + try { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [ + 'menus' => $snapshot, + 'tenant_id' => $this->getTenantId(), + ]); + + if ($response->successful()) { + session()->forget("menu_order_snapshot_{$validated['env']}"); + + return response()->json([ + 'success' => true, + 'message' => '순서가 이전 상태로 복원되었습니다.', + 'hasSnapshot' => false, + ]); + } + + return response()->json([ + 'error' => $response->json('error', '원격 서버 오류'), + ], $response->status()); + } catch (\Exception $e) { + return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500); + } + } + + /** + * 메뉴 순서 재정렬 API (외부 서버에서 호출) + */ + public function reorder(Request $request): JsonResponse + { + $apiKey = $request->header('X-Menu-Sync-Key'); + $validKey = config('app.menu_sync_api_key'); + + if (empty($validKey) || $apiKey !== $validKey) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $validated = $request->validate([ + 'menus' => 'required|array', + 'tenant_id' => 'nullable|integer', + ]); + + $tenantId = $validated['tenant_id'] ?? $this->getTenantId(); + $updated = $this->applyOrder($validated['menus'], $tenantId); + + return response()->json([ + 'success' => true, + 'message' => "{$updated}개 메뉴 순서가 업데이트되었습니다.", + 'updated' => $updated, + ]); + } + + /** + * 메뉴 트리를 순서 매핑 배열로 변환 + */ + private function buildOrderMap(array $menus, ?string $parentName = null): array + { + $result = []; + foreach ($menus as $menu) { + $item = [ + 'name' => $menu['name'], + 'parent_name' => $parentName, + 'sort_order' => $menu['sort_order'] ?? 0, + 'children' => [], + ]; + + if (! empty($menu['children'])) { + $item['children'] = $this->buildOrderMap($menu['children'], $menu['name']); + } + + $result[] = $item; + } + + return $result; + } + + /** + * 순서 매핑을 DB에 적용 (재귀) + */ + private function applyOrder(array $orderMap, int $tenantId, ?int $parentId = null): int + { + $updated = 0; + + foreach ($orderMap as $item) { + $query = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', $item['name']); + + // parent_name으로 부모 매칭 + if (! empty($item['parent_name'])) { + $parent = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', $item['parent_name']) + ->first(); + $resolvedParentId = $parent?->id; + } else { + $resolvedParentId = null; + } + + // parentId 파라미터가 있으면 우선 사용 (재귀 호출 시) + $targetParentId = $parentId ?? $resolvedParentId; + $query->where('parent_id', $targetParentId); + + $menu = $query->first(); + if ($menu) { + $changes = []; + if ($menu->sort_order !== ($item['sort_order'] ?? 0)) { + $changes['sort_order'] = $item['sort_order'] ?? 0; + } + if ($menu->parent_id !== $targetParentId) { + $changes['parent_id'] = $targetParentId; + } + + if (! empty($changes)) { + $menu->update($changes); + $updated++; + } + + // 자식 메뉴 처리 + if (! empty($item['children'])) { + $updated += $this->applyOrder($item['children'], $tenantId, $menu->id); + } + } + } + + return $updated; + } + /** * 메뉴 Import */ diff --git a/resources/views/menus/sync.blade.php b/resources/views/menus/sync.blade.php index b659c75b..f33a98a9 100644 --- a/resources/views/menus/sync.blade.php +++ b/resources/views/menus/sync.blade.php @@ -98,6 +98,14 @@ class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"> class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded transition-colors flex items-center gap-1"> Push → + + @endif @@ -274,6 +282,81 @@ function updateSelectedCount(side) { } }); + // 순서 동기화 Push + async function pushOrder() { + const envName = selectedEnv === 'dev' ? '개발' : '운영'; + if (!confirm(`로컬 메뉴 순서를 ${envName} 서버에 적용하시겠습니까?`)) { + return; + } + + const btn = document.getElementById('btnPushOrder'); + btn.disabled = true; + btn.textContent = '동기화 중...'; + + try { + const response = await fetch('{{ route("menus.sync.push-order") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ env: selectedEnv }) + }); + + const result = await response.json(); + if (result.success) { + alert(result.message); + location.reload(); + } else { + alert(result.error || '순서 동기화 실패'); + } + } catch (e) { + alert('오류 발생: ' + e.message); + } finally { + btn.disabled = false; + btn.textContent = '순서 동기화 →'; + } + } + + // 순서 동기화 되돌리기 + async function undoOrder() { + if (!confirm('순서 동기화를 취소하시겠습니까? 원격 서버의 메뉴 순서가 이전 상태로 복원됩니다.')) { + return; + } + + const btn = document.getElementById('btnUndoOrder'); + btn.disabled = true; + btn.textContent = '복원 중...'; + + try { + const response = await fetch('{{ route("menus.sync.undo-order") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ env: selectedEnv }) + }); + + const result = await response.json(); + if (result.success) { + alert(result.message); + location.reload(); + } else { + alert(result.error || '되돌리기 실패'); + } + } catch (e) { + alert('오류 발생: ' + e.message); + } finally { + btn.disabled = false; + btn.textContent = '↩ 되돌리기'; + } + } + // 상위 메뉴 체크 시 하위 메뉴도 선택/해제 function toggleChildren(checkbox) { const menuGroup = checkbox.closest('.menu-group'); diff --git a/routes/web.php b/routes/web.php index d4756eeb..c9f4ff8d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -86,6 +86,7 @@ Route::prefix('menu-sync')->group(function () { Route::get('/export', [MenuSyncController::class, 'export']); Route::post('/import', [MenuSyncController::class, 'import']); + Route::post('/reorder', [MenuSyncController::class, 'reorder']); }); Route::prefix('common-code-sync')->group(function () { @@ -186,6 +187,8 @@ Route::post('/test', [MenuSyncController::class, 'testConnection'])->name('test'); Route::post('/push', [MenuSyncController::class, 'push'])->name('push'); Route::post('/pull', [MenuSyncController::class, 'pull'])->name('pull'); + Route::post('/push-order', [MenuSyncController::class, 'pushOrder'])->name('push-order'); + Route::post('/undo-order', [MenuSyncController::class, 'undoOrder'])->name('undo-order'); }); });