feat: 메뉴 관리 UI 개선
- 부모메뉴 선택 시 트리 구조 순서 + 인덴트 적용 - 아이콘 선택 UI를 이모지에서 Heroicons SVG로 변경 - 확장 옵션 기본 펼침 상태로 변경
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user