feat: [sidebar] 사이드바 메뉴 즐겨찾기 기능 추가

- MenuFavorite 모델 생성 (menu_favorites 테이블)
- SidebarMenuService에 즐겨찾기 CRUD 메서드 추가
- MenuFavoriteController 생성 (toggle/reorder API)
- 사이드바 상단에 즐겨찾기 섹션 표시
- 메뉴 아이템에 별 아이콘 추가 (hover 시 표시, 토글)
- 최대 10개 제한, 리프 메뉴만 대상
This commit is contained in:
김보곤
2026-03-06 14:34:27 +09:00
parent 53fb5103ac
commit 2906825c33
8 changed files with 296 additions and 1 deletions

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\SidebarMenuService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MenuFavoriteController extends Controller
{
public function __construct(
private SidebarMenuService $sidebarMenuService
) {}
public function toggle(Request $request): JsonResponse
{
$request->validate([
'menu_id' => 'required|integer|exists:menus,id',
]);
$result = $this->sidebarMenuService->toggleFavorite(
auth()->id(),
$request->integer('menu_id')
);
return response()->json($result);
}
public function reorder(Request $request): JsonResponse
{
$request->validate([
'menu_ids' => 'required|array',
'menu_ids.*' => 'integer',
]);
$this->sidebarMenuService->reorderFavorites(
auth()->id(),
$request->input('menu_ids')
);
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Commons;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MenuFavorite extends Model
{
protected $fillable = [
'tenant_id',
'user_id',
'menu_id',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
];
public function menu(): BelongsTo
{
return $this->belongsTo(Menu::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId)->orderBy('sort_order');
}
}

View File

@@ -60,6 +60,8 @@ public function boot(): void
'mainMenus' => $menusBySection['main'], 'mainMenus' => $menusBySection['main'],
'toolsMenus' => $menusBySection['tools'], 'toolsMenus' => $menusBySection['tools'],
'labsMenus' => $menusBySection['labs'], 'labsMenus' => $menusBySection['labs'],
'favoriteMenus' => $menuService->getFavoriteMenus(),
'favoriteMenuIds' => $menuService->getFavoriteMenuIds(),
]); ]);
}); });
} }

View File

@@ -4,6 +4,7 @@
use App\Models\Boards\Board; use App\Models\Boards\Board;
use App\Models\Commons\Menu; use App\Models\Commons\Menu;
use App\Models\Commons\MenuFavorite;
use App\Models\Tenants\Department; use App\Models\Tenants\Department;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -297,6 +298,100 @@ private static function hasMoreSpecificPrefixMenu(string $currentPath, string $m
return $result; return $result;
} }
// ─── 즐겨찾기 기능 ───
private const MAX_FAVORITES = 10;
/**
* 사용자의 즐겨찾기 메뉴 목록 조회
*/
public function getFavoriteMenus(?int $userId = null): Collection
{
$userId = $userId ?? auth()->id();
if (! $userId) {
return collect();
}
$tenantId = auth()->user()?->tenant_id ?? 1;
return MenuFavorite::where('tenant_id', $tenantId)
->forUser($userId)
->with(['menu' => fn ($q) => $q->withoutGlobalScopes()])
->get()
->filter(fn ($fav) => $fav->menu && $fav->menu->is_active)
->values();
}
/**
* 즐겨찾기 메뉴 ID 배열 (별 아이콘 활성 판단용)
*/
public function getFavoriteMenuIds(?int $userId = null): array
{
$userId = $userId ?? auth()->id();
if (! $userId) {
return [];
}
$tenantId = auth()->user()?->tenant_id ?? 1;
return MenuFavorite::where('tenant_id', $tenantId)
->where('user_id', $userId)
->pluck('menu_id')
->toArray();
}
/**
* 즐겨찾기 토글 (추가/제거)
*/
public function toggleFavorite(int $userId, int $menuId): array
{
$tenantId = auth()->user()?->tenant_id ?? 1;
$existing = MenuFavorite::where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('menu_id', $menuId)
->first();
if ($existing) {
$existing->delete();
return ['action' => 'removed'];
}
// 최대 개수 체크
$count = MenuFavorite::where('tenant_id', $tenantId)
->where('user_id', $userId)
->count();
if ($count >= self::MAX_FAVORITES) {
return ['action' => 'max_reached', 'max' => self::MAX_FAVORITES];
}
MenuFavorite::create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'menu_id' => $menuId,
'sort_order' => $count,
]);
return ['action' => 'added'];
}
/**
* 즐겨찾기 순서 변경
*/
public function reorderFavorites(int $userId, array $menuIds): void
{
$tenantId = auth()->user()?->tenant_id ?? 1;
foreach ($menuIds as $order => $menuId) {
MenuFavorite::where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('menu_id', $menuId)
->update(['sort_order' => $order]);
}
}
/** /**
* 메뉴 또는 자식 메뉴가 활성 상태인지 확인 * 메뉴 또는 자식 메뉴가 활성 상태인지 확인
*/ */

View File

@@ -0,0 +1,65 @@
@props(['favorites' => collect()])
@if($favorites->isNotEmpty())
<li id="sidebar-favorites-section" class="pb-2 mb-1">
<button
onclick="toggleMenuGroup('menu-group-favorites')"
class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 rounded"
style="padding-left: 0.75rem"
>
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span class="sidebar-text">즐겨찾기</span>
</span>
<svg
id="menu-group-favorites-icon"
class="w-3 h-3 transition-transform sidebar-text rotate-180"
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="menu-group-favorites" class="menu-group-content space-y-0.5 mt-1">
@foreach($favorites as $fav)
@if($fav->menu)
@php
$menu = $fav->menu;
$sidebarMenuService = app(\App\Services\SidebarMenuService::class);
$isActive = $sidebarMenuService->isMenuActive($menu);
$url = $menu->url;
$routeName = $menu->getRouteName();
if ($routeName && !str_contains($routeName, '*') && \Route::has($routeName)) {
$url = route($routeName);
}
$activeClass = $isActive
? 'bg-primary text-white hover:bg-primary'
: 'text-gray-700 hover:bg-gray-100';
@endphp
<li>
<a href="{{ $url }}"
class="group flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm {{ $activeClass }}"
style="padding-left: 1.5rem"
title="{{ $menu->name }}"
>
@if($menu->icon)
<x-sidebar.menu-icon :icon="$menu->icon" />
@endif
<span class="sidebar-text flex-1">{{ $menu->name }}</span>
<button type="button"
onclick="event.preventDefault(); event.stopPropagation(); toggleMenuFavorite({{ $menu->id }}, this)"
class="sidebar-text shrink-0 p-0.5 rounded transition-colors text-yellow-400 hover:text-yellow-500"
title="즐겨찾기 해제">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</button>
</a>
</li>
@endif
@endforeach
</ul>
</li>
@endif

View File

@@ -22,6 +22,9 @@
$target = $menu->is_external ? '_blank' : '_self'; $target = $menu->is_external ? '_blank' : '_self';
// 즐겨찾기 여부
$isFavorited = isset($favoriteMenuIds) && in_array($menu->id, $favoriteMenuIds);
// 메뉴 뱃지 확인 (라우트명 또는 URL 기준) // 메뉴 뱃지 확인 (라우트명 또는 URL 기준)
$badgeCount = 0; $badgeCount = 0;
$badgeColor = '#ef4444'; $badgeColor = '#ef4444';
@@ -42,7 +45,7 @@
} }
@endphp @endphp
<li> <li class="group">
<a href="{{ $url }}" <a href="{{ $url }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm {{ $activeClass }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm {{ $activeClass }}"
style="padding-left: {{ $paddingLeft }}" style="padding-left: {{ $paddingLeft }}"
@@ -62,5 +65,15 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm {{ $activeClass }}"
@if($menu->is_external) @if($menu->is_external)
<x-sidebar.menu-icon icon="external-link" class="w-3 h-3 opacity-50" /> <x-sidebar.menu-icon icon="external-link" class="w-3 h-3 opacity-50" />
@endif @endif
{{-- 즐겨찾기 아이콘 --}}
<button type="button"
onclick="event.preventDefault(); event.stopPropagation(); toggleMenuFavorite({{ $menu->id }}, this)"
class="sidebar-text fav-star shrink-0 p-0.5 rounded transition-all {{ $isFavorited ? 'text-yellow-400 fav-active' : 'text-gray-300 opacity-0 group-hover:opacity-100' }}"
title="{{ $isFavorited ? '즐겨찾기 해제' : '즐겨찾기 추가' }}"
data-menu-id="{{ $menu->id }}">
<svg class="w-3.5 h-3.5" fill="{{ $isFavorited ? 'currentColor' : 'none' }}" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</button>
</a> </a>
</li> </li>

View File

@@ -117,6 +117,9 @@ class="w-full border-gray-300 rounded-lg text-sm focus:ring-primary focus:border
</div> </div>
<ul class="space-y-1"> <ul class="space-y-1">
{{-- Favorites Section --}}
<x-sidebar.favorites-section :favorites="$favoriteMenus" />
{{-- Main Section Menus (Dynamic from DB) --}} {{-- Main Section Menus (Dynamic from DB) --}}
<x-sidebar.menu-tree :menus="$mainMenus" /> <x-sidebar.menu-tree :menus="$mainMenus" />
@@ -140,6 +143,10 @@ class="w-full border-gray-300 rounded-lg text-sm focus:ring-primary focus:border
</aside> </aside>
<style> <style>
/* ========== 즐겨찾기 별 아이콘 ========== */
.fav-star.fav-active { opacity: 1 !important; }
.sidebar-collapsed .fav-star { display: none !important; }
/* ========== 메뉴 검색 스타일 ========== */ /* ========== 메뉴 검색 스타일 ========== */
.menu-search-highlight { .menu-search-highlight {
background-color: #fef08a; background-color: #fef08a;
@@ -680,6 +687,33 @@ function toggleMenuGroup(groupId) {
} }
} }
// ========== 즐겨찾기 토글 ==========
async function toggleMenuFavorite(menuId, btnEl) {
try {
const res = await fetch('{{ route("menu-favorites.toggle") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ menu_id: menuId }),
});
const data = await res.json();
if (data.action === 'max_reached') {
alert('즐겨찾기는 최대 ' + (data.max || 10) + '개까지 등록할 수 있습니다.');
return;
}
// 페이지 새로고침으로 사이드바 갱신 (가장 안정적)
window.location.reload();
} catch (e) {
console.error('즐겨찾기 토글 실패:', e);
}
}
// ========== R&D Labs 사이드바 스크롤 함수 ========== // ========== R&D Labs 사이드바 스크롤 함수 ==========
// 사이드바를 최하단으로 스크롤하고 위치 저장 // 사이드바를 최하단으로 스크롤하고 위치 저장

View File

@@ -125,6 +125,12 @@
Route::middleware(['auth', 'hq.member', 'password.changed'])->group(function () { Route::middleware(['auth', 'hq.member', 'password.changed'])->group(function () {
Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
// 메뉴 즐겨찾기 API
Route::prefix('menu-favorites')->name('menu-favorites.')->group(function () {
Route::post('/toggle', [\App\Http\Controllers\Api\MenuFavoriteController::class, 'toggle'])->name('toggle');
Route::post('/reorder', [\App\Http\Controllers\Api\MenuFavoriteController::class, 'reorder'])->name('reorder');
});
// 테넌트 전환 // 테넌트 전환
Route::post('/tenant/switch', [TenantController::class, 'switch'])->name('tenant.switch'); Route::post('/tenant/switch', [TenantController::class, 'switch'])->name('tenant.switch');