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');
});
});