메뉴 관리 HTMX 에러 수정 및 개발도구 메뉴 동적 렌더링

- HTMX 응답 에러 수정: JSON 래핑 대신 HTML 직접 반환
  - MenuController, GlobalMenuController의 index 메소드 수정
  - index.blade.php, global-index.blade.php의 JSON 파싱 로직 제거

- 메뉴 options 필드 검증 추가
  - StoreMenuRequest, UpdateMenuRequest에 options 필드 추가
  - section 변경이 정상 저장되도록 수정

- 개발도구 메뉴 하드코딩 제거, DB 기반 동적 렌더링
  - sidebar.blade.php에서 하드코딩된 메뉴 제거
  - tools-menu.blade.php 컴포넌트 신규 생성
  - section=tools 메뉴가 하단 고정 영역에 동적 표시
This commit is contained in:
2025-12-18 11:19:07 +09:00
parent 7205826db1
commit c94e1cff41
8 changed files with 90 additions and 52 deletions

View File

@@ -18,18 +18,18 @@ public function __construct(
/**
* 글로벌 메뉴 목록 조회
*/
public function index(Request $request): JsonResponse
public function index(Request $request): JsonResponse|\Illuminate\Http\Response
{
$menus = $this->menuService->getGlobalMenus(
$request->all(),
$request->integer('per_page', 100) // 글로벌 메뉴는 많지 않으므로 페이지당 100개
);
// HTMX 요청인 경우 HTML 반환
// HTMX 요청인 경우 HTML 직접 반환 (JSON 래핑 없이)
if ($request->header('HX-Request')) {
$html = view('menus.partials.global-table', compact('menus'))->render();
return response()->json(['html' => $html]);
return response($html)->header('Content-Type', 'text/html');
}
// 일반 API 요청인 경우 JSON 반환

View File

@@ -18,7 +18,7 @@ public function __construct(
/**
* 메뉴 목록 조회
*/
public function index(Request $request): JsonResponse
public function index(Request $request): JsonResponse|\Illuminate\Http\Response
{
$tenantId = session('selected_tenant_id');
$importMode = $request->get('mode') === 'import' && $tenantId;
@@ -34,11 +34,11 @@ public function index(Request $request): JsonResponse
);
}
// HTMX 요청인 경우 HTML 반환
// HTMX 요청인 경우 HTML 직접 반환 (JSON 래핑 없이)
if ($request->header('HX-Request')) {
$html = view('menus.partials.table', compact('menus', 'importMode'))->render();
return response()->json(['html' => $html]);
return response($html)->header('Content-Type', 'text/html');
}
// 일반 API 요청인 경우 JSON 반환

View File

@@ -31,6 +31,9 @@ public function rules(): array
'hidden' => 'nullable|boolean',
'is_external' => 'nullable|boolean',
'external_url' => 'nullable|string|max:255|required_if:is_external,1',
'options' => 'nullable|array',
'options.section' => 'nullable|string|in:main,tools,labs',
'options.meta' => 'nullable|array',
];
}

View File

@@ -42,6 +42,9 @@ function ($attribute, $value, $fail) use ($menuId) {
'hidden' => 'nullable|boolean',
'is_external' => 'nullable|boolean',
'external_url' => 'nullable|string|max:255|required_if:is_external,1',
'options' => 'nullable|array',
'options.section' => 'nullable|string|in:main,tools,labs',
'options.meta' => 'nullable|array',
];
}

View File

@@ -0,0 +1,68 @@
@props(['menus'])
@php
$sidebarMenuService = app(\App\Services\SidebarMenuService::class);
@endphp
@foreach($menus as $toolsGroup)
@php
$groupId = 'tools-group-' . $toolsGroup->id;
$children = $toolsGroup->menuChildren ?? collect();
$hasChildren = $children->isNotEmpty();
$isExpanded = $sidebarMenuService->isMenuOrChildActive($toolsGroup);
@endphp
{{-- 그룹 헤더 (접기/펼치기 버튼) --}}
<button
onclick="toggleGroup('{{ $groupId }}')"
class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-600 uppercase tracking-wider hover:bg-gray-100 rounded"
>
<span class="flex items-center gap-2">
@if($toolsGroup->icon)
<x-sidebar.menu-icon :icon="$toolsGroup->icon" class="w-4 h-4 text-orange-500" />
@else
<svg class="w-4 h-4 text-orange-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@endif
<span class="sidebar-text">{{ $toolsGroup->name }}</span>
</span>
<svg
id="{{ $groupId }}-icon"
class="w-3 h-3 transition-transform sidebar-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{{-- 하위 메뉴 목록 --}}
<ul id="{{ $groupId }}" class="space-y-1 mt-1">
@if($hasChildren)
@foreach($children as $child)
@php
$isActive = $sidebarMenuService->isMenuActive($child);
$url = $child->is_external ? $child->external_url : $child->url;
@endphp
<li>
<a href="{{ $url }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ $isActive ? 'bg-primary text-white hover:bg-primary' : '' }}"
title="{{ $child->name }}"
@if($child->is_external) target="_blank" rel="noopener noreferrer" @endif>
@if($child->icon)
<x-sidebar.menu-icon :icon="$child->icon" class="w-4 h-4 flex-shrink-0" />
@else
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
@endif
<span class="font-medium sidebar-text">{{ $child->name }}</span>
</a>
</li>
@endforeach
@endif
</ul>
@endforeach

View File

@@ -110,13 +110,11 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
});
// HTMX 응답 처리 + SortableJS 초기화
// 서버가 HTML을 직접 반환하므로 HTMX가 자동으로 swap 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'menu-table') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
initGlobalMenuSortable();
}
// 테이블 로드 후 SortableJS 초기화
initGlobalMenuSortable();
}
});

View File

@@ -214,14 +214,11 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
});
// HTMX 응답 처리 + SortableJS 초기화
// 서버가 HTML을 직접 반환하므로 HTMX가 자동으로 swap 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'menu-table') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
// 테이블 로드 후 SortableJS 초기화
initMenuSortable();
}
// 테이블 로드 후 SortableJS 초기화
initMenuSortable();
}
});

View File

@@ -39,43 +39,12 @@ class="sidebar-collapsed-only hidden w-full p-2 text-xl font-bold text-gray-900
</ul>
</nav>
<!-- 개발 도구 (하단 고정) -->
<!-- 개발 도구 (하단 고정, DB 기반) -->
@if(!empty($toolsMenus) && $toolsMenus->count() > 0)
<div class="border-t border-gray-200 p-2 bg-gray-50">
<button onclick="toggleGroup('dev-tools-group')" class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-600 uppercase tracking-wider hover:bg-gray-100 rounded">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-orange-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="sidebar-text">개발 도구</span>
</span>
<svg id="dev-tools-group-icon" class="w-3 h-3 transition-transform sidebar-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<ul id="dev-tools-group" class="space-y-1 mt-1">
<li>
<a href="{{ route('dev-tools.flow-tester.index') }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('dev-tools.flow-tester.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
title="API 플로우 테스터">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium sidebar-text">API 플로우 테스터</span>
</a>
</li>
<li>
<a href="{{ route('dev-tools.api-logs.index') }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('dev-tools.api-logs.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
title="API 요청 로그">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="font-medium sidebar-text">API 요청 로그</span>
</a>
</li>
</ul>
<x-sidebar.tools-menu :menus="$toolsMenus" />
</div>
@endif
</div>
</aside>