diff --git a/app/Helpers/admin_helper.php b/app/Helpers/admin_helper.php index fd09a53..69ea503 100644 --- a/app/Helpers/admin_helper.php +++ b/app/Helpers/admin_helper.php @@ -475,6 +475,68 @@ if (! function_exists('site_nav_link_matches_current')) { } } +if (! function_exists('menu_active_child_for_parent')) { + /** + * 같은 부모 아래 형제 소메뉴 중, 현재 요청에 해당하는 항목을 하나만 고른다. + * + * 짧은 mm_link(예: bag/designated-shops)가 긴 경로(bag/designated-shops/browse)와 + * 동시에 prefix 규칙으로 매칭될 때, 가장 구체적인 경로(일치한 후보 문자열 길이 최대)만 활성으로 본다. + * 길이가 같으면 mm_num이 작은 항목을 선택(동일 URL이 여러 메뉴에 매핑된 경우 등). + * + * @param object{children?: array} $parentNavItem + * @param list $dashboardPathAliases + * + * @return object|null 활성으로 표시할 자식 노드(mm_idx 등 포함), 없으면 null + */ + function menu_active_child_for_parent(object $parentNavItem, string $currentPath, array $dashboardPathAliases = []): ?object + { + $children = $parentNavItem->children ?? []; + if ($children === []) { + return null; + } + + $best = null; + $bestLen = -1; + $bestMmNum = PHP_INT_MAX; + + foreach ($children as $child) { + $mmLink = $child->mm_link ?? null; + $maxLen = -1; + foreach (menu_link_candidate_paths($mmLink, $currentPath) as $cand) { + if (menu_single_path_matches_request($cand, $currentPath, $dashboardPathAliases)) { + $maxLen = max($maxLen, strlen($cand)); + } + } + if ($maxLen < 0) { + continue; + } + $mmNum = (int) ($child->mm_num ?? 0); + if ($maxLen > $bestLen || ($maxLen === $bestLen && $mmNum < $bestMmNum)) { + $bestLen = $maxLen; + $bestMmNum = $mmNum; + $best = $child; + } + } + + return $best; + } +} + +if (! function_exists('site_nav_active_child_for_parent')) { + /** + * 사이트 상단 메뉴 전용 호환 래퍼. + * + * @param object{children?: array} $parentNavItem + * @param list $dashboardPathAliases + * + * @return object|null + */ + function site_nav_active_child_for_parent(object $parentNavItem, string $currentPath, array $dashboardPathAliases = []): ?object + { + return menu_active_child_for_parent($parentNavItem, $currentPath, $dashboardPathAliases); + } +} + if (! function_exists('session_user_nav_display')) { /** * 상단 메뉴바용: 로그인 사용자 이름·역할 표시 diff --git a/app/Views/admin/layout.php b/app/Views/admin/layout.php index 1fc8265..6339d2c 100644 --- a/app/Views/admin/layout.php +++ b/app/Views/admin/layout.php @@ -93,14 +93,10 @@ body { overflow: hidden; } children); $parentLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath); + $activeChild = $hasChildren ? menu_active_child_for_parent($navItem, $currentPath, []) : null; $parentIsCurrent = $adminNavItemIsCurrent($navItem->mm_link ?? null); - if (! $parentIsCurrent && $hasChildren) { - foreach ($navItem->children as $ch) { - if ($adminNavItemIsCurrent($ch->mm_link ?? null)) { - $parentIsCurrent = true; - break; - } - } + if (! $parentIsCurrent && $activeChild !== null) { + $parentIsCurrent = true; } ?>
@@ -115,7 +111,8 @@ body { overflow: hidden; } children as $child): ?> mm_link ?? null, $currentPath); - $childIsCurrent = $adminNavItemIsCurrent($child->mm_link ?? null); + $childIsCurrent = $activeChild !== null + && (int) ($child->mm_idx ?? 0) === (int) ($activeChild->mm_idx ?? -1); ?> mm_link ?? null, $currentPath); + $activeChild = ! empty($navItem->children) + ? menu_active_child_for_parent($navItem, $currentPath, $dashboardPathAliases) + : null; $isActive = site_nav_link_matches_current($navItem->mm_link ?? null, $currentPath, $dashboardPathAliases); - if (! $isActive && ! empty($navItem->children)) { - foreach ($navItem->children as $ch) { - if (site_nav_link_matches_current($ch->mm_link ?? null, $currentPath, $dashboardPathAliases)) { - $isActive = true; - break; - } - } + if (! $isActive && $activeChild !== null) { + $isActive = true; } ?>
@@ -93,7 +91,8 @@ body { overflow: hidden; } children as $child): ?> mm_link ?? null, $currentPath); - $childCurrent = menu_link_matches_request($child->mm_link ?? null, $currentPath, $dashboardPathAliases); + $childCurrent = $activeChild !== null + && (int) ($child->mm_idx ?? 0) === (int) ($activeChild->mm_idx ?? -1); ?> { test('지정판매소 목록 접근', async ({ page }) => { await page.goto('/bag/designated-shops'); - await expect(page).toHaveURL(/\/admin\/designated-shops/); + await expect(page).toHaveURL(/\/bag\/designated-shops$/); + await expect(page.getByText('지정판매소 관리').first()).toBeVisible(); + await expect(page.getByRole('link', { name: '지정판매소 등록' })).toBeVisible(); + }); + + test('지정판매소 조회 전용(browse) 접근', async ({ page }) => { + await page.goto('/bag/designated-shops/browse'); + await expect(page).toHaveURL(/\/bag\/designated-shops\/browse/); + await expect(page.getByText('지정판매소 조회').first()).toBeVisible(); + await expect(page.getByRole('link', { name: '지정판매소 등록' })).toHaveCount(0); + }); + + test('지정판매소 소메뉴는 현재 경로 1개만 활성화', async ({ page }) => { + const activeSubmenu = page.locator('nav a.text-blue-700.font-semibold.bg-blue-50'); + + await page.goto('/bag/designated-shops'); + await expect(page).toHaveURL(/\/bag\/designated-shops$/); + await expect(activeSubmenu.filter({ hasText: '지정판매소 관리' })).toHaveCount(1); + await expect(activeSubmenu.filter({ hasText: '지정판매소 바코드출력' })).toHaveCount(0); + await expect(activeSubmenu.filter({ hasText: '지정판매소 조회' })).toHaveCount(0); + + await page.goto('/bag/designated-shops/browse'); + await expect(page).toHaveURL(/\/bag\/designated-shops\/browse/); + await expect(activeSubmenu.filter({ hasText: '지정판매소 조회' })).toHaveCount(1); + await expect(activeSubmenu.filter({ hasText: '지정판매소 관리' })).toHaveCount(0); }); test('지자체 관리는 Super Admin 전용 — 지자체관리자 접근 시 리다이렉트', async ({ page }) => {