- MenuFavorite 모델 생성 (menu_favorites 테이블) - SidebarMenuService에 즐겨찾기 CRUD 메서드 추가 - MenuFavoriteController 생성 (toggle/reorder API) - 사이드바 상단에 즐겨찾기 섹션 표시 - 메뉴 아이템에 별 아이콘 추가 (hover 시 표시, 토글) - 최대 10개 제한, 리프 메뉴만 대상
416 lines
13 KiB
PHP
416 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Boards\Board;
|
|
use App\Models\Commons\Menu;
|
|
use App\Models\Commons\MenuFavorite;
|
|
use App\Models\Tenants\Department;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class SidebarMenuService
|
|
{
|
|
/**
|
|
* 현재 사용자가 접근 가능한 메뉴 트리 조회
|
|
*/
|
|
public function getUserMenuTree(?User $user = null): Collection
|
|
{
|
|
$user = $user ?? auth()->user();
|
|
// 로그인한 사용자의 tenant_id만 사용 (session 값 무시)
|
|
$tenantId = $user?->tenant_id ?? 1;
|
|
|
|
// 테넌트의 모든 활성 메뉴 조회
|
|
$allMenus = Menu::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->where('hidden', false)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
// 슈퍼관리자는 모든 메뉴 표시
|
|
if ($user && $user->is_super_admin) {
|
|
return $this->buildMenuTree($allMenus);
|
|
}
|
|
|
|
// 일반 사용자: 부서 권한 기반 메뉴 ID 조회
|
|
$permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId);
|
|
|
|
// 역할 기반 필터링 + 부서 권한 필터링
|
|
$filteredMenus = $allMenus->filter(function ($menu) use ($user, $permittedMenuIds) {
|
|
$requiredRole = $menu->getRequiresRole();
|
|
|
|
// super_admin 역할 필요시 슈퍼관리자만
|
|
if ($requiredRole === 'super_admin') {
|
|
return $user && $user->is_super_admin;
|
|
}
|
|
|
|
// 기타 역할 체크
|
|
if ($requiredRole && ! ($user && $user->hasRole($requiredRole))) {
|
|
return false;
|
|
}
|
|
|
|
// 단체 파트너에게 숨길 메뉴 체크
|
|
if ($menu->getOption('hide_for_group_partner') && $user?->salesPartner?->isGroup()) {
|
|
return false;
|
|
}
|
|
|
|
// 부서 권한 체크: 허용된 메뉴 ID만 표시
|
|
return in_array($menu->id, $permittedMenuIds);
|
|
});
|
|
|
|
return $this->buildMenuTree($filteredMenus);
|
|
}
|
|
|
|
/**
|
|
* 사용자가 접근 가능한 메뉴 ID 목록 조회 (부서 권한 기반)
|
|
*/
|
|
private function getPermittedMenuIds(?User $user, int $tenantId): array
|
|
{
|
|
if (! $user) {
|
|
return [];
|
|
}
|
|
|
|
// 사용자의 부서 ID 조회
|
|
$departmentIds = DB::table('department_user')
|
|
->where('user_id', $user->id)
|
|
->pluck('department_id')
|
|
->toArray();
|
|
|
|
if (empty($departmentIds)) {
|
|
return [];
|
|
}
|
|
|
|
$now = now();
|
|
|
|
// permission_overrides 테이블에서 부서에 ALLOW된 menu:*.view 권한 조회
|
|
$permittedMenuIds = DB::table('permission_overrides as po')
|
|
->join('permissions as p', 'p.id', '=', 'po.permission_id')
|
|
->where('po.model_type', Department::class)
|
|
->whereIn('po.model_id', $departmentIds)
|
|
->where('po.tenant_id', $tenantId)
|
|
->where('po.effect', 1) // ALLOW
|
|
->where('p.name', 'like', 'menu:%.view')
|
|
->whereNull('po.deleted_at')
|
|
->where(function ($query) use ($now) {
|
|
$query->whereNull('po.effective_from')
|
|
->orWhere('po.effective_from', '<=', $now);
|
|
})
|
|
->where(function ($query) use ($now) {
|
|
$query->whereNull('po.effective_to')
|
|
->orWhere('po.effective_to', '>=', $now);
|
|
})
|
|
->pluck('p.name')
|
|
->map(function ($name) {
|
|
// menu:{id}.view에서 id 추출
|
|
if (preg_match('/^menu:(\d+)\.view$/', $name, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
return null;
|
|
})
|
|
->filter()
|
|
->unique()
|
|
->toArray();
|
|
|
|
return $permittedMenuIds;
|
|
}
|
|
|
|
/**
|
|
* 섹션별 메뉴 조회 (main, tools, labs)
|
|
*/
|
|
public function getMenusBySection(?User $user = null): array
|
|
{
|
|
$menuTree = $this->getUserMenuTree($user);
|
|
|
|
return [
|
|
'main' => $menuTree->filter(fn ($m) => $m->getSection() === 'main')->values(),
|
|
'tools' => $menuTree->filter(fn ($m) => $m->getSection() === 'tools')->values(),
|
|
'labs' => $menuTree->filter(fn ($m) => $m->getSection() === 'labs')->values(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 메뉴 트리 구성
|
|
*/
|
|
private function buildMenuTree(Collection $menus, ?int $parentId = null): Collection
|
|
{
|
|
return $menus->where('parent_id', $parentId)
|
|
->map(function ($menu) use ($menus) {
|
|
$menu->menuChildren = $this->buildMenuTree($menus, $menu->id);
|
|
|
|
return $menu;
|
|
})
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* 현재 라우트가 메뉴와 일치하는지 확인
|
|
*/
|
|
public function isMenuActive(Menu $menu): bool
|
|
{
|
|
$routeName = $menu->getRouteName();
|
|
|
|
if ($routeName) {
|
|
// 라우트 패턴 매칭 (예: pm.* → pm.index, pm.projects.index 등)
|
|
if (str_ends_with($routeName, '.*')) {
|
|
$prefix = substr($routeName, 0, -2);
|
|
|
|
return request()->routeIs($prefix.'*');
|
|
}
|
|
|
|
return request()->routeIs($routeName);
|
|
}
|
|
|
|
// URL 매칭
|
|
if ($menu->url) {
|
|
$currentPath = '/'.ltrim(request()->path(), '/');
|
|
|
|
// /boards/{id}/posts 패턴인 경우 게시판 기반 메뉴 매칭
|
|
$boardMenuUrl = $this->getBoardMenuUrl();
|
|
if ($boardMenuUrl) {
|
|
return $menu->url === $boardMenuUrl;
|
|
}
|
|
|
|
// 정확한 URL 매칭
|
|
if ($currentPath === $menu->url) {
|
|
return true;
|
|
}
|
|
|
|
// prefix 매칭: 더 구체적인 메뉴가 있으면 덜 구체적인 prefix 매칭 비활성화
|
|
// (예: /esign/templates/5/fields 접속 시, /esign/templates 메뉴만 활성화, /esign 메뉴는 비활성)
|
|
if (str_starts_with($currentPath, $menu->url.'/')) {
|
|
return ! self::hasExactMenuMatch($currentPath)
|
|
&& ! self::hasMoreSpecificPrefixMenu($currentPath, $menu->url);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 현재 /boards/{code}/posts 경로인 경우 해당 게시판의 메뉴 URL 반환
|
|
*/
|
|
private function getBoardMenuUrl(): ?string
|
|
{
|
|
static $cachedUrl = null;
|
|
static $calculated = false;
|
|
|
|
if ($calculated) {
|
|
return $cachedUrl;
|
|
}
|
|
|
|
$calculated = true;
|
|
$currentPath = request()->path();
|
|
|
|
// /boards/{code}/posts 패턴 확인 (code는 영문자로 시작하는 문자열)
|
|
if (! preg_match('#^boards/([a-zA-Z][a-zA-Z0-9_-]*)(/posts.*)?$#', $currentPath, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$boardCode = $matches[1];
|
|
|
|
// 게시판 조회 (board_code로)
|
|
$board = Board::withoutGlobalScopes()->where('board_code', $boardCode)->first();
|
|
if (! $board) {
|
|
// 게시판 없으면 게시판 관리 메뉴
|
|
$cachedUrl = '/boards';
|
|
|
|
return $cachedUrl;
|
|
}
|
|
|
|
// 시스템 게시판: /customer-center/{board_code}
|
|
// 테넌트 게시판: /boards/{board_code}
|
|
if ($board->is_system) {
|
|
$expectedUrl = '/customer-center/'.$board->board_code;
|
|
} else {
|
|
$expectedUrl = '/boards/'.$board->board_code;
|
|
}
|
|
|
|
// 해당 URL의 메뉴가 존재하는지 확인
|
|
$menuExists = Menu::withoutGlobalScopes()
|
|
->where('url', $expectedUrl)
|
|
->where('is_active', true)
|
|
->exists();
|
|
|
|
if ($menuExists) {
|
|
$cachedUrl = $expectedUrl;
|
|
} else {
|
|
// 메뉴 없으면 게시판 관리 메뉴
|
|
$cachedUrl = '/boards';
|
|
}
|
|
|
|
return $cachedUrl;
|
|
}
|
|
|
|
/**
|
|
* 현재 URL에 정확히 매칭되는 메뉴가 존재하는지 확인 (캐시)
|
|
*/
|
|
private static ?bool $exactMatchCache = null;
|
|
|
|
private static function hasExactMenuMatch(string $currentPath): bool
|
|
{
|
|
if (self::$exactMatchCache !== null) {
|
|
return self::$exactMatchCache;
|
|
}
|
|
|
|
$tenantId = auth()->user()?->tenant_id ?? 1;
|
|
|
|
self::$exactMatchCache = Menu::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->where('url', $currentPath)
|
|
->exists();
|
|
|
|
return self::$exactMatchCache;
|
|
}
|
|
|
|
/**
|
|
* 현재 경로에 대해 더 구체적인 prefix를 가진 메뉴가 있는지 확인
|
|
* (예: 현재 메뉴 URL이 /esign이고, /esign/templates 메뉴도 있으면 true)
|
|
*/
|
|
private static array $prefixMenuCache = [];
|
|
|
|
private static function hasMoreSpecificPrefixMenu(string $currentPath, string $menuUrl): bool
|
|
{
|
|
$cacheKey = $currentPath.'|'.$menuUrl;
|
|
if (isset(self::$prefixMenuCache[$cacheKey])) {
|
|
return self::$prefixMenuCache[$cacheKey];
|
|
}
|
|
|
|
$tenantId = auth()->user()?->tenant_id ?? 1;
|
|
|
|
// 현재 경로의 prefix이면서 이 메뉴 URL보다 긴 URL을 가진 활성 메뉴가 있는지 확인
|
|
$result = Menu::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->whereNotNull('url')
|
|
->where('url', '!=', $menuUrl)
|
|
->whereRaw('LENGTH(url) > ?', [strlen($menuUrl)])
|
|
->whereRaw("? LIKE CONCAT(url, '/%')", [$currentPath])
|
|
->exists();
|
|
|
|
self::$prefixMenuCache[$cacheKey] = $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]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 메뉴 또는 자식 메뉴가 활성 상태인지 확인
|
|
*/
|
|
public function isMenuOrChildActive(Menu $menu): bool
|
|
{
|
|
if ($this->isMenuActive($menu)) {
|
|
return true;
|
|
}
|
|
|
|
// 자식 메뉴 중 활성 상태가 있는지 확인
|
|
if (isset($menu->menuChildren) && $menu->menuChildren->isNotEmpty()) {
|
|
foreach ($menu->menuChildren as $child) {
|
|
if ($this->isMenuOrChildActive($child)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|