feat: 메뉴 관리 UI 개선

- 부모메뉴 선택 시 트리 구조 순서 + 인덴트 적용
- 아이콘 선택 UI를 이모지에서 Heroicons SVG로 변경
- 확장 옵션 기본 펼침 상태로 변경
This commit is contained in:
2025-12-19 12:58:37 +09:00
parent 7ee078ba1b
commit 0c1501f08b
3 changed files with 164 additions and 21 deletions

View File

@@ -162,7 +162,7 @@ private function buildChildren(Menu $parent, Collection $allMenus): Collection
}
/**
* 부모 메뉴 목록 조회 (드롭다운용)
* 부모 메뉴 목록 조회 (드롭다운용) - 트리 구조 순서로 정렬, depth 정보 포함
*/
public function getParentMenus(?int $tenantId = null): Collection
{
@@ -171,7 +171,7 @@ public function getParentMenus(?int $tenantId = null): Collection
$query = Menu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name');
->orderBy('id');
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
@@ -181,7 +181,10 @@ public function getParentMenus(?int $tenantId = null): Collection
$query->whereNull('tenant_id');
}
return $query->get();
$allMenus = $query->get();
// 트리 구조로 정렬 (depth 정보 포함)
return $this->flattenMenuTree($allMenus);
}
/**
@@ -523,15 +526,18 @@ private function compactSiblings(?int $parentId): void
}
/**
* 글로벌 부모 메뉴 목록 조회 (드롭다운용)
* 글로벌 부모 메뉴 목록 조회 (드롭다운용) - 트리 구조 순서로 정렬, depth 정보 포함
*/
public function getGlobalParentMenus(): Collection
{
return GlobalMenu::query()
$allMenus = GlobalMenu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->orderBy('id')
->get();
// 트리 구조로 정렬 (depth 정보 포함)
return $this->flattenMenuTree($allMenus);
}
/**

View File

@@ -6,7 +6,10 @@
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 메뉴 생성</h1>
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<x-sidebar.menu-icon icon="menu" class="w-6 h-6" />
메뉴 생성
</h1>
<a href="{{ route('menus.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
@@ -26,7 +29,9 @@
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">최상위 메뉴</option>
@foreach($parentMenus as $parent)
<option value="{{ $parent->id }}">{{ $parent->name }}</option>
<option value="{{ $parent->id }}">
{{ str_repeat('── ', $parent->depth ?? 0) }}{{ $parent->icon ? $parent->icon . ' ' : '' }}{{ $parent->name }}
</option>
@endforeach
</select>
</div>
@@ -55,9 +60,39 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<label for="icon" class="block text-sm font-medium text-gray-700 mb-2">
아이콘
</label>
<input type="text" name="icon" id="icon"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="📋">
<div class="flex items-center gap-3 mb-2">
<div id="icon-preview" class="w-12 h-12 flex items-center justify-center border border-gray-300 rounded-lg bg-gray-50 text-gray-600">
<x-sidebar.menu-icon icon="menu" class="w-6 h-6" />
</div>
<input type="text" name="icon" id="icon"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="아래에서 선택하거나 직접 입력">
</div>
<!-- 아이콘 샘플 그리드 -->
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50">
<p class="text-xs text-gray-500 mb-2">사용 가능한 아이콘 (클릭하여 선택)</p>
<div class="grid grid-cols-6 sm:grid-cols-9 md:grid-cols-13 gap-1">
@php
$sampleIcons = [
'home', 'folder', 'chart-bar', 'calendar', 'building',
'users', 'user-group', 'menu', 'shield-check', 'key',
'cog', 'beaker', 'code', 'document-text', 'clipboard-list',
'cube', 'collection', 'tag', 'database', 'terminal',
'server', 'adjustments', 'sparkles', 'lightning-bolt', 'puzzle', 'external-link',
];
@endphp
@foreach($sampleIcons as $iconKey)
<button type="button"
onclick="selectIcon('{{ $iconKey }}')"
class="icon-btn flex flex-col items-center gap-0.5 p-2 hover:bg-white hover:shadow-sm rounded-lg transition cursor-pointer border border-transparent hover:border-blue-300 group"
data-icon="{{ $iconKey }}"
title="{{ $iconKey }}">
<x-sidebar.menu-icon :icon="$iconKey" class="w-5 h-5 text-gray-500 group-hover:text-blue-600" />
<span class="text-[9px] text-gray-400 group-hover:text-blue-600 truncate max-w-full leading-none">{{ $iconKey }}</span>
</button>
@endforeach
</div>
</div>
</div>
<!-- 정렬 순서 -->
@@ -114,11 +149,11 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700">확장 옵션</h3>
<button type="button" onclick="toggleOptionsSection()" class="text-xs text-blue-600 hover:text-blue-800">
<span id="options-toggle-text">펼치</span>
<span id="options-toggle-text"></span>
</button>
</div>
<div id="options-section" class="hidden space-y-4">
<div id="options-section" class="space-y-4">
<!-- section -->
<div>
<label for="option_section" class="block text-sm font-medium text-gray-700 mb-1">
@@ -164,6 +199,37 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
@push('scripts')
<script>
// 아이콘 선택
window.selectIcon = function(iconKey) {
document.getElementById('icon').value = iconKey;
updateIconPreview(iconKey);
// 선택 상태 표시
document.querySelectorAll('.icon-btn').forEach(btn => {
btn.classList.remove('bg-blue-50', 'border-blue-400');
if (btn.dataset.icon === iconKey) {
btn.classList.add('bg-blue-50', 'border-blue-400');
}
});
};
// 아이콘 미리보기 업데이트
window.updateIconPreview = function(iconKey) {
const preview = document.getElementById('icon-preview');
const iconBtn = document.querySelector(`.icon-btn[data-icon="${iconKey}"]`);
if (iconBtn) {
const svg = iconBtn.querySelector('svg');
if (svg) {
const clonedSvg = svg.cloneNode(true);
clonedSvg.classList.remove('w-5', 'h-5');
clonedSvg.classList.add('w-6', 'h-6');
preview.innerHTML = '';
preview.appendChild(clonedSvg);
}
} else if (iconKey) {
preview.innerHTML = `<span class="text-xs text-gray-400">${iconKey}</span>`;
}
};
// 외부 링크 체크박스 토글
document.getElementById('is_external').addEventListener('change', function() {
const externalUrlGroup = document.getElementById('external-url-group');

View File

@@ -6,7 +6,10 @@
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 메뉴 수정</h1>
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<x-sidebar.menu-icon icon="menu" class="w-6 h-6" />
메뉴 수정
</h1>
<a href="{{ route('menus.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
@@ -29,7 +32,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
@foreach($parentMenus as $parent)
@if($parent->id != $menu->id)
<option value="{{ $parent->id }}" {{ $menu->parent_id == $parent->id ? 'selected' : '' }}>
{{ $parent->name }}
{{ str_repeat('── ', $parent->depth ?? 0) }}{{ $parent->icon ? $parent->icon . ' ' : '' }}{{ $parent->name }}
</option>
@endif
@endforeach
@@ -58,14 +61,51 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
</div>
<!-- 아이콘 -->
@php
$currentIcon = old('icon', $menu->icon);
$sampleIcons = [
'home', 'folder', 'chart-bar', 'calendar', 'building',
'users', 'user-group', 'menu', 'shield-check', 'key',
'cog', 'beaker', 'code', 'document-text', 'clipboard-list',
'cube', 'collection', 'tag', 'database', 'terminal',
'server', 'adjustments', 'sparkles', 'lightning-bolt', 'puzzle', 'external-link',
];
@endphp
<div class="mb-4">
<label for="icon" class="block text-sm font-medium text-gray-700 mb-2">
아이콘
</label>
<input type="text" name="icon" id="icon"
value="{{ old('icon', $menu->icon) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="📋">
<div class="flex items-center gap-3 mb-2">
<div id="icon-preview" class="w-12 h-12 flex items-center justify-center border border-gray-300 rounded-lg bg-gray-50 text-gray-600">
@if($currentIcon && in_array($currentIcon, $sampleIcons))
<x-sidebar.menu-icon :icon="$currentIcon" class="w-6 h-6" />
@elseif($currentIcon)
<span class="text-xs text-gray-400">{{ $currentIcon }}</span>
@else
<x-sidebar.menu-icon icon="menu" class="w-6 h-6" />
@endif
</div>
<input type="text" name="icon" id="icon"
value="{{ $currentIcon }}"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="아래에서 선택하거나 직접 입력">
</div>
<!-- 아이콘 샘플 그리드 -->
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50">
<p class="text-xs text-gray-500 mb-2">사용 가능한 아이콘 (클릭하여 선택)</p>
<div class="grid grid-cols-6 sm:grid-cols-9 md:grid-cols-13 gap-1">
@foreach($sampleIcons as $iconKey)
<button type="button"
onclick="selectIcon('{{ $iconKey }}')"
class="icon-btn flex flex-col items-center gap-0.5 p-2 hover:bg-white hover:shadow-sm rounded-lg transition cursor-pointer border border-transparent hover:border-blue-300 group {{ $currentIcon === $iconKey ? 'bg-blue-50 border-blue-400' : '' }}"
data-icon="{{ $iconKey }}"
title="{{ $iconKey }}">
<x-sidebar.menu-icon :icon="$iconKey" class="w-5 h-5 text-gray-500 group-hover:text-blue-600" />
<span class="text-[9px] text-gray-400 group-hover:text-blue-600 truncate max-w-full leading-none">{{ $iconKey }}</span>
</button>
@endforeach
</div>
</div>
</div>
<!-- 정렬 순서 -->
@@ -138,11 +178,11 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
@endif
</h3>
<button type="button" onclick="toggleOptionsSection()" class="text-xs text-blue-600 hover:text-blue-800">
<span id="options-toggle-text">{{ $hasOptions ? '접기' : '펼치기' }}</span>
<span id="options-toggle-text">접기</span>
</button>
</div>
<div id="options-section" class="{{ $hasOptions ? '' : 'hidden' }} space-y-4">
<div id="options-section" class="space-y-4">
<!-- section -->
<div>
<label for="option_section" class="block text-sm font-medium text-gray-700 mb-1">
@@ -188,6 +228,37 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
@push('scripts')
<script>
// 아이콘 선택
window.selectIcon = function(iconKey) {
document.getElementById('icon').value = iconKey;
updateIconPreview(iconKey);
// 선택 상태 표시
document.querySelectorAll('.icon-btn').forEach(btn => {
btn.classList.remove('bg-blue-50', 'border-blue-400');
if (btn.dataset.icon === iconKey) {
btn.classList.add('bg-blue-50', 'border-blue-400');
}
});
};
// 아이콘 미리보기 업데이트
window.updateIconPreview = function(iconKey) {
const preview = document.getElementById('icon-preview');
const iconBtn = document.querySelector(`.icon-btn[data-icon="${iconKey}"]`);
if (iconBtn) {
const svg = iconBtn.querySelector('svg');
if (svg) {
const clonedSvg = svg.cloneNode(true);
clonedSvg.classList.remove('w-5', 'h-5');
clonedSvg.classList.add('w-6', 'h-6');
preview.innerHTML = '';
preview.appendChild(clonedSvg);
}
} else if (iconKey) {
preview.innerHTML = `<span class="text-xs text-gray-400">${iconKey}</span>`;
}
};
// 외부 링크 체크박스 토글
document.getElementById('is_external').addEventListener('change', function() {
const externalUrlGroup = document.getElementById('external-url-group');