임동욱 임동욱 08-18
merge: basic 레이아웃 사이드바 시안 구현
@5dd72739569e0795c1ba1de90ccff996e542675f
app/(protected)/(basic)/_components/admin-shell.tsx
--- app/(protected)/(basic)/_components/admin-shell.tsx
+++ app/(protected)/(basic)/_components/admin-shell.tsx
@@ -1,6 +1,6 @@
 'use client';
 
-import { useSidebar } from '@/app/(protected)/(basic)/_hooks/use-sidebar';
+import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
 import { AdminFooter } from './admin-footer';
 import { AdminHeader } from './admin-header';
 import { AdminSidebar } from './admin-sidebar';
@@ -9,25 +9,25 @@
   children: React.ReactNode;
   /** 헤더 프로필에 표시할 관리자 이름 — 세션을 읽는 상위 Server Component가 넘긴다. */
   adminName: string;
+  /** 계정 권한에 맞춰 서버가 구성한 사이드바 메뉴. */
+  menu: SidebarMenu;
 }
 
 /**
- * Basic 레이아웃의 상호작용 경계 — 헤더·사이드바·풋터를 조립하고 사이드바 상태(useSidebar)를
- * 소유한다. page 콘텐츠(children)는 상위 Server Component가 렌더한 결과를 props로 그대로
- * 통과시켜 RSC를 유지한다 — 이 컴포넌트 자신은 children의 내용을 알거나 가공하지 않는다.
+ * Basic 레이아웃의 상호작용 경계 — 헤더·사이드바·풋터를 조립한다. page 콘텐츠(children)는
+ * 상위 Server Component가 렌더한 결과를 props로 그대로 통과시켜 RSC를 유지한다 — 이 컴포넌트
+ * 자신은 children의 내용을 알거나 가공하지 않는다.
+ *
+ * 사이드바 열림/접힘 상태(useSidebar)는 더 이상 여기서 소유하지 않는다. 시안의 헤더·사이드바
+ * 어디에도 그 토글이 없어 헤더 구현 때 함께 걷어냈다 — 접힘 모양이 시안에 정의되면 그때
+ * 다시 붙인다.
  */
-export function AdminShell({ children, adminName }: AdminShellProps) {
-  const sidebar = useSidebar();
-
+export function AdminShell({ children, adminName, menu }: AdminShellProps) {
   return (
     <div className="flex min-h-screen flex-col">
       <AdminHeader adminName={adminName} />
       <div className="flex flex-1">
-        <AdminSidebar
-          isMobileOpen={sidebar.isMobileOpen}
-          isDesktopCollapsed={sidebar.isDesktopCollapsed}
-          onCloseMobile={sidebar.closeMobile}
-        />
+        <AdminSidebar menu={menu} />
         <div className="flex flex-1 flex-col">
           <main className="flex flex-1 flex-col">{children}</main>
           <AdminFooter />
 
app/(protected)/(basic)/_components/admin-sidebar.module.scss (added)
+++ app/(protected)/(basic)/_components/admin-sidebar.module.scss
@@ -0,0 +1,255 @@
+// Basic 레이아웃 사이드바 — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                                snb-area 3041:11860
+//
+// 시안에서 디자인시스템 컴포넌트로 정의된 건 btn-ico(FoxIconButton)와 아이콘들뿐이고, 1·2·3depth
+// 항목은 @fox에 없어 사이드바 전용으로 짰다(사용자 확정). 색·간격·radius·타이포는 전부 @fox
+// 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.sidebar {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+  // 시안 240px. Figma가 변수로 export하지 않는다.
+  inline-size: 24rem;
+  padding: fox.padding(5);
+  border-inline-end: fox.border(1) solid fox.color(border-neutral-subtler);
+  // 시안은 회색에서 시작해 70% 지점부터 옅은 파랑으로 넘어간다.
+  background: linear-gradient(
+    to bottom,
+    fox.color(surface-neutral-gray) 70%,
+    fox.color(surface-theme-primary)
+  );
+  overflow-y: auto;
+}
+
+// ── 상단: 검색 + 사이드바 토글 ───────────────────────────────────────────────
+.top {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block-end: fox.spacing(bottom-md);
+}
+
+.search {
+  flex: 1 0 0;
+  min-inline-size: 0;
+}
+
+// FoxInput의 기본 테두리는 input-border(진한 회색)인데 시안 검색창은 border-neutral-subtler다.
+// 크기·radius·안쪽 여백은 md가 이미 시안과 같아 색만 덮는다. 명시도가 같아 로드 순서에
+// 좌우되지 않도록 `:global()`을 겹쳐 이기게 한다.
+.search :global(.fox-input__box) {
+  border-color: fox.color(border-neutral-subtler);
+}
+
+// ── 섹션 ────────────────────────────────────────────────────────────────────
+.section {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  padding-block-end: fox.spacing(bottom-md);
+}
+
+// 즐겨찾기만 아래 구분선이 있고 위 여백이 없다.
+.sectionFavorites {
+  padding-block-end: fox.spacing(bottom-sm);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
+}
+
+.sectionNav {
+  padding-block-start: fox.spacing(top-sm);
+}
+
+.sectionTitle {
+  inline-size: 100%;
+  margin: 0;
+  padding-block-end: fox.spacing(bottom-sm);
+  color: fox.color(font-neutral-subtler);
+  font-family: fox.font-family(title);
+  font-size: fox.font-size(heading-2xsm);
+  font-weight: fox.font-weight(bold);
+  line-height: 1.4;
+  letter-spacing: -0.025em;
+}
+
+.list {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  align-items: flex-start;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+// ── 즐겨찾기 항목 ───────────────────────────────────────────────────────────
+.favorite {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(1);
+  padding-inline: fox.padding(2);
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  // 시안 13px. heading/2xsm과 값이 같지만 이쪽은 본문 계열이라 리터럴로 둔다.
+  font-size: 1.3rem;
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+  text-decoration: none;
+}
+
+// ── 1depth ──────────────────────────────────────────────────────────────────
+.item {
+  inline-size: 100%;
+}
+
+.itemButton {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(4);
+  padding-inline: fox.padding(3);
+  border: none;
+  border-radius: fox.radius(3);
+  background: fox.color(action-secondary);
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(title);
+  // 시안 14px.
+  font-size: 1.4rem;
+  font-weight: fox.font-weight(bold);
+  line-height: 1.4;
+  letter-spacing: -0.025em;
+  text-align: start;
+  text-decoration: none;
+  cursor: pointer;
+  // 시안이 배경 위에 곱하기로 얹는다 — 그라데이션 위에서 색이 겹쳐 보이게 한다.
+  mix-blend-mode: multiply;
+}
+
+.itemButtonActive {
+  background: fox.color(action-secondary-active);
+  color: fox.color(font-neutral-strong-inverse);
+  // 활성 항목은 배경을 그대로 덮는다.
+  mix-blend-mode: normal;
+}
+
+.itemIcon {
+  display: inline-flex;
+  flex-shrink: 0;
+  inline-size: fox.icon(4);
+  block-size: fox.icon(4);
+}
+
+.itemIcon > svg {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+.itemLabel {
+  flex: 1 0 0;
+  min-inline-size: 0;
+}
+
+// ── 2depth ──────────────────────────────────────────────────────────────────
+.childGroup {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  align-items: flex-start;
+  margin: fox.gap(2) 0 0;
+  padding: fox.padding(3);
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(3);
+  background: fox.color(surface-neutral-default);
+  list-style: none;
+}
+
+.child {
+  inline-size: 100%;
+}
+
+.childRow {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(2);
+  padding-inline: fox.padding(3);
+  border: none;
+  border-radius: fox.radius(3);
+  background: fox.color(action-secondary);
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(title);
+  font-size: 1.4rem;
+  font-weight: fox.font-weight(bold);
+  line-height: 1.4;
+  letter-spacing: -0.025em;
+  text-align: start;
+  text-decoration: none;
+  cursor: pointer;
+}
+
+.childRowActive {
+  color: fox.color(font-theme-secondary);
+}
+
+// 시안은 별·캐럿 자리를 항상 잡아 두고 필요 없을 때 투명하게만 만든다 — 라벨 시작 위치가
+// 항목마다 흔들리지 않게 하기 위해서다.
+.childMark {
+  display: inline-flex;
+  flex-shrink: 0;
+  inline-size: fox.icon(2);
+  block-size: fox.icon(2);
+}
+
+.childMark > svg {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+.childMarkHidden {
+  opacity: 0;
+}
+
+.childLabel {
+  flex: 1 0 0;
+  min-inline-size: 0;
+}
+
+// ── 3depth ──────────────────────────────────────────────────────────────────
+.leafGroup {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  align-items: flex-start;
+  margin: 0;
+  padding-block: fox.padding(2);
+  padding-inline-start: fox.padding(7);
+  padding-inline-end: fox.padding(2);
+  border-radius: fox.radius(3);
+  list-style: none;
+}
+
+.leaf {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(1);
+  padding-inline: fox.padding(2);
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: 1.3rem;
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+  text-decoration: none;
+}
+
+.leafActive {
+  color: fox.color(font-theme-secondary);
+}
app/(protected)/(basic)/_components/admin-sidebar.tsx
--- app/(protected)/(basic)/_components/admin-sidebar.tsx
+++ app/(protected)/(basic)/_components/admin-sidebar.tsx
@@ -1,124 +1,219 @@
-import { Sidebar } from '@/components/ui/sidebar';
-import { SidebarGroup } from '@/components/ui/sidebar-group';
-import { SidebarLink } from '@/components/ui/sidebar-link';
-import {
-  useSidebarNav,
-  type SidebarNavNode,
-} from '@/app/(protected)/(basic)/_hooks/use-sidebar-nav';
+'use client';
 
-// 신규 화면이 추가될 때마다 이 트리에 항목을 늘린다. 기획(Figma ADM_MEM_101)의 사이드바는
-// 그룹 헤딩(라우트 없음) 아래 자식 링크를 두는 2단 구조다 — 「학생 회원」은 「회원정보관리」
-// 그룹의 자식이다(「학생회원관리」가 아니다).
-//
-// 시안에는 이 외에도 포인트관리·콘텐츠관리·시스템관리 3개 그룹이 더 있지만, 전부 대응하는
-// 화면/라우트가 아직 없어 지금 추가하면 눌러도 갈 곳 없는 죽은 메뉴가 된다. 각 화면이 실제로
-// 구현될 때 해당 그룹과 자식 링크를 추가한다.
-const SIDEBAR_NAV_TREE: SidebarNavNode[] = [
-  { type: 'link', label: '홈', href: '/' },
-  {
-    type: 'group',
-    label: '회원정보관리',
-    children: [{ type: 'link', label: '학생 회원', href: '/students' }],
-  },
-  {
-    // 기획(Figma ADM_ADM_101)의 화면경로가 「관리자 회원 < 관리자정보관리」다 —
-    // 「회원정보관리」가 아니라 별도 그룹의 자식이다.
-    type: 'group',
-    label: '관리자정보관리',
-    children: [{ type: 'link', label: '관리자 회원', href: '/admins' }],
-  },
-  {
-    // 기획(Figma ADM_ITM_101)의 화면경로가 「아이템 관리 < 꾸미기아이템관리」다. 셋트아이템은
-    // 같은 화면(/decoration-items)을 유형 탭으로 전환해 재사용하므로 별도 링크를 두지 않는다
-    // (decoration-item-query.ts 상단 주석 참조).
-    type: 'group',
-    label: '꾸미기아이템관리',
-    children: [{ type: 'link', label: '아이템 관리', href: '/decoration-items' }],
-  },
-  {
-    // 기획(Figma A_BOA_006/012/014) 사이드바의 그룹명이 「게시판관리(고객센터)」다.
-    type: 'group',
-    label: '게시판관리(고객센터)',
-    children: [
-      { type: 'link', label: '공지사항', href: '/boards/notices' },
-      { type: 'link', label: '1:1문의', href: '/boards/inquiries' },
-      { type: 'link', label: 'FAQ', href: '/boards/faqs' },
-    ],
-  },
-];
+import Link from 'next/link';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxInput } from '@fox/core/components/fox-input';
+import {
+  FoxCaretDownIcon,
+  FoxCaretUpIcon,
+  FoxMagnifyingGlassIcon,
+  FoxSidebarSimpleIcon,
+  FoxStarIcon,
+} from '@fox/core/icons';
+import type {
+  SidebarMenu,
+  SidebarMenuChild,
+  SidebarMenuItem,
+} from '@/lib/domain/sidebar-menu';
+import { useSidebarNav, type SidebarNavState } from '../_hooks/use-sidebar-nav';
+import styles from './admin-sidebar.module.scss';
+import { SidebarMenuIcon } from './sidebar-menu-icon';
 
 interface AdminSidebarProps {
-  isMobileOpen: boolean;
-  isDesktopCollapsed: boolean;
-  onCloseMobile: () => void;
+  /** 서버가 계정 권한에 맞춰 구성해 준 메뉴. 이 컴포넌트는 라우트 목록을 알지 못한다. */
+  menu: SidebarMenu;
 }
 
 /**
- * 사이드바 배치 — 비주얼 셸(Sidebar)과 내비 항목(SidebarGroup/SidebarLink)은 design 공용
- * 컴포넌트를 그대로 쓰고, 데스크톱 push/접힘과 모바일 오버레이+백드롭 등 반응형 배치(공용
- * 컴포넌트가 제공하지 않음)만 className 패스스루로 처리한다. 전부 Tailwind 반응형
- * 클래스(CSS-first) — JS로 브레이크포인트를 판별하지 않는다.
+ * Basic 레이아웃 사이드바 — 시안 snb-area(3041:11860)를 옮겼다. 검색·즐겨찾기·섹션별 GNB로
+ * 이루어지고, GNB는 1depth > 2depth > 3depth 중첩이다.
  *
- * 그룹 펼침/접힘 상태와 활성 링크·그룹 판정은 `useSidebarNav` 훅(ViewModel)이 소유한다 —
- * 이 컴포넌트는 트리 데이터를 훅에 넘기고 반환값으로 렌더링만 담당한다.
+ * **메뉴 데이터를 소유하지 않는다.** 권한마다 항목이 달라 매 요청 서버가 조회해 넘겨주며
+ * (`fetchSidebarMenu`), 여기서는 받은 트리를 그리고 펼침·활성 상태만 판정한다. 그래서 새 화면이
+ * 생겨도 이 파일은 손대지 않는다.
  *
- * 주의(공용 컴포넌트 부족분 — 실증됨): SidebarLink는 아직 collapsed(md:w-16) 상태 전용
- * 아이콘 표시 variant를 제공하지 않는다. 글자 단위 세로 줄바꿈 문제 자체는 SidebarLink의
- * `min-w-0 truncate`로 완화돼 있지만(한 줄 유지 + 말줄임), 접힘 폭에서 라벨 텍스트가
- * 말줄임으로 잘려 보이는 근본 한계는 남아 있다 — design 레인 소관(§4.2 공용 컴포넌트
- * 신설)이라 이번 범위에서는 수정하지 않는다.
+ * 시안에 없어 넣지 않은 것: 검색 입력의 실제 동작(제출 대상이 정해지지 않음)과 즐겨찾기
+ * 추가/해제. 별 아이콘은 시안대로 표시만 한다.
  */
-export function AdminSidebar({
-  isMobileOpen,
-  isDesktopCollapsed,
-  onCloseMobile,
-}: AdminSidebarProps) {
-  const { isLinkActive, isGroupExpanded, toggleGroup } =
-    useSidebarNav(SIDEBAR_NAV_TREE);
+export function AdminSidebar({ menu }: AdminSidebarProps) {
+  const nav = useSidebarNav(menu);
 
   return (
+    <nav className={styles.sidebar} aria-label="주요 메뉴">
+      <div className={styles.top}>
+        <div className={styles.search}>
+          <FoxInput
+            size="md"
+            placeholder="검색어를 입력하세요."
+            icon={<FoxMagnifyingGlassIcon />}
+            aria-label="메뉴 검색"
+          />
+        </div>
+        <FoxIconButton
+          size="xsm"
+          type="ghost"
+          icon={<FoxSidebarSimpleIcon />}
+          label="사이드바 접기"
+        />
+      </div>
+
+      {menu.favorites.length > 0 && (
+        <section className={`${styles.section} ${styles.sectionFavorites}`}>
+          <h2 className={styles.sectionTitle}>즐겨찾기</h2>
+          <ul className={styles.list}>
+            {menu.favorites.map((favorite) => (
+              <li key={favorite.id} className={styles.item}>
+                <Link href={favorite.href} className={styles.favorite}>
+                  <span className={styles.childMark}>
+                    <FoxStarIcon weight="fill" />
+                  </span>
+                  {favorite.label}
+                </Link>
+              </li>
+            ))}
+          </ul>
+        </section>
+      )}
+
+      {menu.sections.map((section) => (
+        <section
+          key={section.id}
+          className={`${styles.section} ${styles.sectionNav}`}
+        >
+          <h2 className={styles.sectionTitle}>{section.label}</h2>
+          <ul className={styles.list}>
+            {section.items.map((item) => (
+              <SidebarItem key={item.id} item={item} nav={nav} />
+            ))}
+          </ul>
+        </section>
+      ))}
+    </nav>
+  );
+}
+
+/** 1depth — 자식이 있으면 펼침 버튼, 없으면 링크다. */
+function SidebarItem({
+  item,
+  nav,
+}: {
+  item: SidebarMenuItem;
+  nav: SidebarNavState;
+}) {
+  const hasChildren = (item.children?.length ?? 0) > 0;
+  const isActive = nav.containsActive(item);
+  const isOpen = nav.isItemOpen(item);
+  const rowClass = `${styles.itemButton} ${isActive ? styles.itemButtonActive : ''}`;
+
+  const inner = (
     <>
-      {isMobileOpen && (
+      <span className={styles.itemIcon}>
+        <SidebarMenuIcon name={item.icon} />
+      </span>
+      <span className={styles.itemLabel}>{item.label}</span>
+    </>
+  );
+
+  return (
+    <li className={styles.item}>
+      {hasChildren ? (
         <button
           type="button"
-          aria-label="사이드바 닫기"
-          onClick={onCloseMobile}
-          className="fixed inset-0 z-30 bg-scrim md:hidden"
-        />
+          className={rowClass}
+          aria-expanded={isOpen}
+          onClick={() => nav.toggleItem(item.id)}
+        >
+          {inner}
+        </button>
+      ) : (
+        <Link href={item.href ?? '#'} className={rowClass}>
+          {inner}
+        </Link>
       )}
-      <Sidebar
-        className={`fixed inset-y-0 left-0 z-40 overflow-hidden transition-all duration-200 md:relative md:z-auto md:translate-x-0 ${
-          isMobileOpen ? 'translate-x-0' : '-translate-x-full'
-        } ${isDesktopCollapsed ? 'md:w-16' : 'md:w-64'}`}
-      >
-        {SIDEBAR_NAV_TREE.map((node) =>
-          node.type === 'link' ? (
-            <SidebarLink
-              key={node.href}
-              href={node.href}
-              label={node.label}
-              isActive={isLinkActive(node.href)}
-            />
-          ) : (
-            <SidebarGroup
-              key={node.label}
-              label={node.label}
-              isExpanded={isGroupExpanded(node.label)}
-              onToggle={() => toggleGroup(node.label)}
-            >
-              {node.children.map((link) => (
-                <SidebarLink
-                  key={link.href}
-                  href={link.href}
-                  label={link.label}
-                  isActive={isLinkActive(link.href)}
-                  isNested
-                />
-              ))}
-            </SidebarGroup>
-          )
-        )}
-      </Sidebar>
-    </>
+
+      {hasChildren && isOpen && (
+        <ul className={styles.childGroup}>
+          {item.children?.map((child) => (
+            <SidebarChild key={child.id} child={child} nav={nav} />
+          ))}
+        </ul>
+      )}
+    </li>
+  );
+}
+
+/** 2depth — 자식이 있으면 펼침 버튼(별·캐럿 표시), 없으면 링크다. */
+function SidebarChild({
+  child,
+  nav,
+}: {
+  child: SidebarMenuChild;
+  nav: SidebarNavState;
+}) {
+  const hasChildren = (child.children?.length ?? 0) > 0;
+  const isOpen = nav.isChildOpen(child);
+  const isActive = nav.isActive(child.href);
+  const rowClass = `${styles.childRow} ${
+    isActive || (hasChildren && isOpen) ? styles.childRowActive : ''
+  }`;
+
+  // 시안은 자식이 없는 항목도 별·캐럿 자리를 비워 두고 투명하게만 만든다 — 라벨 시작
+  // 위치를 줄마다 맞추기 위해서다.
+  const mark = (
+    <span
+      className={`${styles.childMark} ${hasChildren ? '' : styles.childMarkHidden}`}
+    >
+      <FoxStarIcon />
+    </span>
+  );
+  const caret = (
+    <span
+      className={`${styles.childMark} ${hasChildren ? '' : styles.childMarkHidden}`}
+    >
+      {isOpen ? <FoxCaretUpIcon /> : <FoxCaretDownIcon />}
+    </span>
+  );
+
+  return (
+    <li className={styles.child}>
+      {hasChildren ? (
+        <button
+          type="button"
+          className={rowClass}
+          aria-expanded={isOpen}
+          onClick={() => nav.toggleChild(child.id)}
+        >
+          {mark}
+          <span className={styles.childLabel}>{child.label}</span>
+          {caret}
+        </button>
+      ) : (
+        <Link href={child.href ?? '#'} className={rowClass}>
+          {mark}
+          <span className={styles.childLabel}>{child.label}</span>
+          {caret}
+        </Link>
+      )}
+
+      {hasChildren && isOpen && (
+        <ul className={styles.leafGroup}>
+          {child.children?.map((leaf) => (
+            <li key={leaf.id}>
+              <Link
+                href={leaf.href}
+                className={`${styles.leaf} ${
+                  nav.isActive(leaf.href) ? styles.leafActive : ''
+                }`}
+              >
+                <span className={styles.childMark}>
+                  <FoxStarIcon />
+                </span>
+                {leaf.label}
+              </Link>
+            </li>
+          ))}
+        </ul>
+      )}
+    </li>
   );
 }
 
app/(protected)/(basic)/_components/sidebar-menu-icon.tsx (added)
+++ app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
@@ -0,0 +1,54 @@
+import type { ComponentType } from 'react';
+import {
+  FoxCalendarStarIcon,
+  FoxChartBarIcon,
+  FoxHouseIcon,
+  FoxIdentificationCardIcon,
+  FoxMedalIcon,
+  FoxNutIcon,
+  FoxPencilSimpleIcon,
+  FoxPhoneCallIcon,
+  FoxPottedPlantIcon,
+  FoxShieldCheckIcon,
+  FoxSquaresFourIcon,
+  FoxUserSquareIcon,
+  FoxUsersThreeIcon,
+  type FoxIconProps,
+} from '@fox/core/icons';
+
+/**
+ * 메뉴 아이콘 이름(백엔드가 주는 문자열) → @fox 아이콘 컴포넌트.
+ *
+ * 화이트리스트로 두는 이유 — @fox에는 아이콘이 1,512종 있고, 이름으로 동적 import를 하면
+ * 번들러가 무엇이 쓰일지 몰라 전부 싣게 된다. 여기 적힌 것만 번들에 들어간다.
+ *
+ * 목록은 시안 1depth가 쓰는 세트다. 백엔드가 새 이름을 보내면 여기에 한 줄 추가한다 —
+ * 모르는 이름이 와도 화면은 깨지지 않고 기본 아이콘으로 그려진다.
+ */
+const ICONS: Record<string, ComponentType<FoxIconProps>> = {
+  house: FoxHouseIcon,
+  'users-three': FoxUsersThreeIcon,
+  'user-square': FoxUserSquareIcon,
+  'potted-plant': FoxPottedPlantIcon,
+  'pencil-simple': FoxPencilSimpleIcon,
+  medal: FoxMedalIcon,
+  'identification-card': FoxIdentificationCardIcon,
+  'calendar-star': FoxCalendarStarIcon,
+  nut: FoxNutIcon,
+  'shield-check': FoxShieldCheckIcon,
+  'phone-call': FoxPhoneCallIcon,
+  'chart-bar': FoxChartBarIcon,
+};
+
+/** 모르는 이름이 왔을 때. 메뉴가 통째로 사라지는 것보다 낫다. */
+const FALLBACK = FoxSquaresFourIcon;
+
+interface SidebarMenuIconProps {
+  name: string;
+}
+
+/** 시안 1depth 아이콘은 Fill 굵기다. */
+export function SidebarMenuIcon({ name }: SidebarMenuIconProps) {
+  const Icon = ICONS[name] ?? FALLBACK;
+  return <Icon weight="fill" />;
+}
app/(protected)/(basic)/_hooks/use-sidebar-nav.ts
--- app/(protected)/(basic)/_hooks/use-sidebar-nav.ts
+++ app/(protected)/(basic)/_hooks/use-sidebar-nav.ts
@@ -1,92 +1,124 @@
 'use client';
 
 import { usePathname } from 'next/navigation';
-import { useCallback, useState } from 'react';
-
-/** 사이드바 최상위 링크 노드 — 그룹에 속하지 않는 단일 링크(예: 홈). */
-export interface SidebarNavLink {
-  type: 'link';
-  label: string;
-  href: string;
-}
-
-/** 사이드바 그룹 노드 — 라우트가 없는 헤딩이며, 자식 링크를 펼침/접힘으로 보여준다. */
-export interface SidebarNavGroup {
-  type: 'group';
-  label: string;
-  children: SidebarNavLink[];
-}
-
-export type SidebarNavNode = SidebarNavLink | SidebarNavGroup;
-
-export interface SidebarNavState {
-  /** href가 현재 경로와 일치하는가(활성 링크 여부). */
-  isLinkActive: (href: string) => boolean;
-  /** 그룹 라벨이 현재 펼침 상태인가. */
-  isGroupExpanded: (label: string) => boolean;
-  /** 그룹 펼침/접힘을 토글한다. */
-  toggleGroup: (label: string) => void;
-}
-
-/** tree에서 현재 pathname을 자식으로 포함하는 그룹의 라벨을 찾는다(없으면 null). */
-function findActiveGroupLabel(
-  tree: SidebarNavNode[],
-  pathname: string
-): string | null {
-  const activeGroup = tree.find(
-    (node): node is SidebarNavGroup =>
-      node.type === 'group' &&
-      node.children.some((link) => link.href === pathname)
-  );
-  return activeGroup?.label ?? null;
-}
+import { useCallback, useMemo, useState } from 'react';
+import type {
+  SidebarMenu,
+  SidebarMenuChild,
+  SidebarMenuItem,
+} from '@/lib/domain/sidebar-menu';
 
 /**
- * 사이드바 내비게이션 트리의 화면 로컬 상태(ViewModel 대응) — 그룹 라벨을 키로 펼침/접힘
- * 여부를 들고 있고, 현재 경로 기준 활성 링크·활성 그룹 판정도 함께 제공한다. 트리 데이터
- * 자체는 호출부가 소유하고 이 훅에 넘긴다(이 훅은 상태·판정 로직만 책임).
+ * 사이드바 펼침·활성 상태(ViewModel). 트리는 서버가 준 값을 그대로 받고, 이 훅은 "지금 어디에
+ * 있는가"와 "무엇이 펼쳐져 있는가"만 판정한다.
  *
- * 현재 경로가 속한 그룹은 항상 펼쳐져 있어야 한다(기획 ADM_MEM_101). 최초 렌더뿐 아니라
- * 클라이언트 내비게이션으로 pathname이 바뀌는 매 순간에도 이 불변식을 지켜야 하므로,
- * `use-sidebar.ts`(모바일 오버레이를 pathname 변화에 맞춰 닫는 로직)가 쓰는 것과 동일한
- * "렌더 중 상태 조정" 패턴(pathname 파생값의 변화를 렌더 중 감지해 즉시 setState)을 쓴다 —
- * effect 안에서 setState하면 `react-hooks/set-state-in-effect` 린트 규칙에 걸리고
- * effect발 cascading render를 유발하므로 이 프로젝트에서는 쓰지 않는다.
- *
- * 사용자가 수동으로 접은 그룹을 강제로 다시 펼치지는 않는다 — activeGroupLabel 자체가
- * 바뀔 때만(= 다른 그룹의 경로로 새로 이동했을 때만) 그 그룹을 편다. 같은 그룹 내 다른
- * 링크로 이동하는 경우(activeGroupLabel 불변)에는 사용자가 접어둔 상태를 그대로 둔다.
+ * 펼침은 **현재 경로에서 파생된 기본값 + 사용자가 누른 값**의 2층이다. 그래서 링크를 눌러
+ * 이동하면 그 가지가 저절로 열리고, 사용자가 직접 접으면 그 선택이 유지된다. 열림 목록을
+ * 상태로만 들고 있으면 이동할 때마다 손으로 다시 열어야 한다.
  */
-export function useSidebarNav(tree: SidebarNavNode[]): SidebarNavState {
+export interface SidebarNavState {
+  /** 이 href가 현재 경로인가. */
+  isActive: (href?: string) => boolean;
+  /** 1depth 항목이 펼쳐져 있는가. */
+  isItemOpen: (item: SidebarMenuItem) => boolean;
+  toggleItem: (id: string) => void;
+  /** 2depth 항목이 펼쳐져 있는가. */
+  isChildOpen: (child: SidebarMenuChild) => boolean;
+  toggleChild: (id: string) => void;
+  /** 자손 중에 현재 경로가 있는가 — 1depth를 활성으로 칠할지 판단한다. */
+  containsActive: (item: SidebarMenuItem) => boolean;
+}
+
+function childContainsPath(child: SidebarMenuChild, pathname: string): boolean {
+  return (
+    child.href === pathname ||
+    (child.children?.some((leaf) => leaf.href === pathname) ?? false)
+  );
+}
+
+function itemContainsPath(item: SidebarMenuItem, pathname: string): boolean {
+  return (
+    item.href === pathname ||
+    (item.children?.some((child) => childContainsPath(child, pathname)) ?? false)
+  );
+}
+
+export function useSidebarNav(menu: SidebarMenu): SidebarNavState {
   const pathname = usePathname();
-  const activeGroupLabel = findActiveGroupLabel(tree, pathname);
+  // 사용자가 직접 누른 것만 담는다. 없으면 경로에서 파생된 기본값을 쓴다.
+  const [itemOverrides, setItemOverrides] = useState<Record<string, boolean>>({});
+  const [childOverrides, setChildOverrides] = useState<Record<string, boolean>>(
+    {}
+  );
 
-  const [expandedGroups, setExpandedGroups] = useState<
-    Record<string, boolean>
-  >(() => (activeGroupLabel ? { [activeGroupLabel]: true } : {}));
+  // 현재 경로를 품은 가지 — 기본 펼침 대상이다.
+  const openByPath = useMemo(() => {
+    const items = new Set<string>();
+    const children = new Set<string>();
 
-  const [prevActiveGroupLabel, setPrevActiveGroupLabel] =
-    useState(activeGroupLabel);
-  if (activeGroupLabel !== prevActiveGroupLabel) {
-    setPrevActiveGroupLabel(activeGroupLabel);
-    if (activeGroupLabel !== null) {
-      setExpandedGroups((prev) => ({ ...prev, [activeGroupLabel]: true }));
+    for (const section of menu.sections) {
+      for (const item of section.items) {
+        if (!itemContainsPath(item, pathname)) {
+          continue;
+        }
+        items.add(item.id);
+        for (const child of item.children ?? []) {
+          if (childContainsPath(child, pathname)) {
+            children.add(child.id);
+          }
+        }
+      }
     }
-  }
 
-  const isLinkActive = useCallback(
-    (href: string) => pathname === href,
+    return { items, children };
+  }, [menu, pathname]);
+
+  const isActive = useCallback(
+    (href?: string) => href !== undefined && href === pathname,
     [pathname]
   );
 
-  const isGroupExpanded = useCallback(
-    (label: string) => Boolean(expandedGroups[label]),
-    [expandedGroups]
+  const isItemOpen = useCallback(
+    (item: SidebarMenuItem) =>
+      itemOverrides[item.id] ?? openByPath.items.has(item.id),
+    [itemOverrides, openByPath]
   );
 
-  const toggleGroup = useCallback((label: string) => {
-    setExpandedGroups((prev) => ({ ...prev, [label]: !prev[label] }));
-  }, []);
+  const isChildOpen = useCallback(
+    (child: SidebarMenuChild) =>
+      childOverrides[child.id] ?? openByPath.children.has(child.id),
+    [childOverrides, openByPath]
+  );
 
-  return { isLinkActive, isGroupExpanded, toggleGroup };
+  const toggleItem = useCallback(
+    (id: string) =>
+      setItemOverrides((prev) => ({
+        ...prev,
+        [id]: !(prev[id] ?? openByPath.items.has(id)),
+      })),
+    [openByPath]
+  );
+
+  const toggleChild = useCallback(
+    (id: string) =>
+      setChildOverrides((prev) => ({
+        ...prev,
+        [id]: !(prev[id] ?? openByPath.children.has(id)),
+      })),
+    [openByPath]
+  );
+
+  const containsActive = useCallback(
+    (item: SidebarMenuItem) => itemContainsPath(item, pathname),
+    [pathname]
+  );
+
+  return {
+    isActive,
+    isItemOpen,
+    toggleItem,
+    isChildOpen,
+    toggleChild,
+    containsActive,
+  };
 }
app/(protected)/(basic)/layout.tsx
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
@@ -1,21 +1,31 @@
 import { getSessionAdmin } from '@/lib/auth/dal';
+import { fetchSidebarMenu } from '@/lib/data/repositories/sidebar-menu-repository';
+import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
 import { AdminShell } from './_components/admin-shell';
+
+const EMPTY_MENU: SidebarMenu = { favorites: [], sections: [] };
 
 // Basic 레이아웃 — 헤더·사이드바·풋터가 있는 일반 관리자 화면 진입점. 보안 강제는 상위
 // `(protected)/layout.tsx`가 담당하므로 여기서는 크롬 조립(AdminShell)만 책임진다. page
 // 콘텐츠는 Server Component로 렌더된 그대로 AdminShell의 children으로 통과한다.
 //
-// 헤더 프로필에 쓸 이름만 세션에서 읽는다. **인증 판단은 하지 않는다** — 레이아웃은 Partial
-// Rendering 때문에 인증 체크 지점으로 부적합하다는 상위 레이아웃의 판단을 그대로 따르므로,
-// redirect하는 `verifySession()`이 아니라 null을 돌려주는 `getSessionAdmin()`을 쓴다.
+// **사이드바 메뉴는 여기서 조회한다.** 계정 권한마다 항목이 달라 화면이 들고 있을 수 없는
+// 값이라, 세션의 권한으로 서버에서 구성해 내려보낸다 — 클라이언트가 나중에 불러오면 첫
+// 페인트에 메뉴가 비었다가 채워지고, 조회에 쓰는 토큰도 브라우저로 내려가야 한다.
+//
+// 세션이 없으면 빈 메뉴다. **인증 판단은 하지 않는다** — 레이아웃은 Partial Rendering 때문에
+// 인증 체크 지점으로 부적합하다는 상위 레이아웃의 판단을 그대로 따른다.
 export default async function Layout({
   children,
 }: Readonly<{
   children: React.ReactNode;
 }>) {
   const admin = await getSessionAdmin();
+  const menu = admin ? await fetchSidebarMenu(admin.roleCode) : EMPTY_MENU;
 
   return (
-    <AdminShell adminName={admin?.name ?? '관리자'}>{children}</AdminShell>
+    <AdminShell adminName={admin?.name ?? '관리자'} menu={menu}>
+      {children}
+    </AdminShell>
   );
 }
 
lib/data/repositories/sidebar-menu-repository.ts (added)
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -0,0 +1,91 @@
+import 'server-only';
+import { cache } from 'react';
+import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
+
+/**
+ * 사이드바 메뉴 Repository — 계정 권한에 맞는 메뉴 트리를 가져온다.
+ *
+ * **아직 백엔드 API가 없다.** 지금은 아래 mock을 돌려주지만, 호출부는 이미 "권한을 넘기면
+ * 트리가 온다"는 계약만 알고 있으므로 API가 생기면 이 파일의 함수 본문만 교체하면 된다
+ * (다른 Repository와 같은 자리·같은 모양이다).
+ *
+ * 권한을 인자로 받는 이유 — 메뉴는 계정마다 다르다. 인자로 두면 `cache()`가 권한별로 따로
+ * 기억해, 한 요청 안에서 여러 번 불러도 한 번만 조회하면서 다른 권한의 결과가 섞이지 않는다.
+ *
+ * 교체할 때는 다른 Repository처럼 `backendFetch` + `getSessionAccessToken`을 쓰고, 응답을
+ * `SidebarMenu`로 매핑하는 `toSidebarMenu`만 추가하면 된다.
+ */
+
+/**
+ * mock 메뉴 — 지금 실제로 존재하는 화면만 담는다. 갈 곳 없는 항목을 넣으면 눌러도 아무 일이
+ * 없어 고장으로 보이므로, 시안에 있는 통합플랫폼 메뉴(기관 관리·역할 관리 등)는 넣지 않았다.
+ *
+ * 라벨과 계층은 기획 시안의 화면경로를 그대로 따른다 — 「학생 회원 < 회원정보관리」,
+ * 「관리자 회원 < 관리자정보관리」, 「아이템 관리 < 꾸미기아이템관리」,
+ * 「공지사항/1:1문의/FAQ < 게시판관리(고객센터)」.
+ *
+ * 아이콘은 시안 1depth가 쓰는 Phosphor 세트에서 골랐다.
+ */
+const MOCK_MENU: SidebarMenu = {
+  favorites: [
+    { id: 'fav-students', label: '학생 회원', href: '/students' },
+    { id: 'fav-admins', label: '관리자 회원', href: '/admins' },
+    { id: 'fav-notices', label: '공지사항', href: '/boards/notices' },
+  ],
+  sections: [
+    {
+      id: 'operation',
+      label: '운영 관리',
+      items: [
+        { id: 'home', label: '홈', icon: 'house', href: '/' },
+        {
+          id: 'members',
+          label: '회원정보관리',
+          icon: 'users-three',
+          children: [
+            { id: 'students', label: '학생 회원', href: '/students' },
+          ],
+        },
+        {
+          id: 'admins',
+          label: '관리자정보관리',
+          icon: 'user-square',
+          children: [{ id: 'admin-members', label: '관리자 회원', href: '/admins' }],
+        },
+        {
+          id: 'decoration',
+          label: '꾸미기아이템관리',
+          icon: 'potted-plant',
+          children: [
+            { id: 'decoration-items', label: '아이템 관리', href: '/decoration-items' },
+          ],
+        },
+        {
+          id: 'boards',
+          label: '게시판관리(고객센터)',
+          icon: 'pencil-simple',
+          children: [
+            { id: 'notices', label: '공지사항', href: '/boards/notices' },
+            { id: 'inquiries', label: '1:1문의', href: '/boards/inquiries' },
+            { id: 'faqs', label: 'FAQ', href: '/boards/faqs' },
+          ],
+        },
+      ],
+    },
+  ],
+};
+
+/**
+ * 권한에 맞는 사이드바 메뉴를 가져온다.
+ *
+ * @param roleCode 세션의 `admRoleCd`(예: `ROLE_SYSTEM`). 지금은 쓰이지 않지만 API가 붙으면
+ *   이 값으로 조회한다 — 인자를 미리 받아 두어야 호출부를 나중에 고치지 않는다.
+ */
+export const fetchSidebarMenu = cache(
+  async (roleCode: string): Promise<SidebarMenu> => {
+    // TODO(백엔드): 권한별 메뉴 API가 생기면 backendFetch 호출로 교체한다. roleCode를 그대로
+    // 넘기면 되고, 아래 mock 반환만 지우면 된다.
+    void roleCode;
+    return MOCK_MENU;
+  }
+);
 
lib/domain/sidebar-menu.ts (added)
+++ lib/domain/sidebar-menu.ts
@@ -0,0 +1,56 @@
+/**
+ * 사이드바 메뉴 도메인 — 시안(snb-area 3041:11860)의 `section > 1depth > 2depth > 3depth`
+ * 중첩을 그대로 옮긴 모양이다.
+ *
+ * **메뉴는 화면이 소유하지 않는다.** 계정 권한마다 보이는 항목이 달라서 매 요청 백엔드가
+ * 구성해 주는 값이며, 컴포넌트는 받은 트리를 그리기만 한다. 그래서 라우트 목록을 컴포넌트
+ * 안에 상수로 두지 않는다.
+ *
+ * 아이콘은 컴포넌트가 아니라 **이름 문자열**로 받는다 — 백엔드가 넘길 수 있는 형태가 문자열
+ * 뿐이고, 이름 → 컴포넌트 변환은 화면 쪽 레지스트리가 맡는다(sidebar-menu-icon.tsx).
+ */
+
+/** 3depth 항목 — 더 내려가지 않는 잎이다. */
+export type SidebarMenuLeaf = {
+  id: string;
+  label: string;
+  href: string;
+};
+
+/** 2depth 항목 — 자식이 있으면 펼침 대상이 되고, 없으면 그 자체가 링크다. */
+export type SidebarMenuChild = {
+  id: string;
+  label: string;
+  /** 자식이 있는 항목은 스스로 이동하지 않을 수 있다. */
+  href?: string;
+  children?: SidebarMenuLeaf[];
+};
+
+/** 1depth 항목 — 아이콘을 갖는 최상위 메뉴다. */
+export type SidebarMenuItem = {
+  id: string;
+  label: string;
+  /** Phosphor 아이콘 이름(kebab-case). 예: `users-three`. */
+  icon: string;
+  href?: string;
+  children?: SidebarMenuChild[];
+};
+
+/** 시안의 "플랫폼 운영"·"서비스"·"플러그인"처럼 제목을 가진 묶음. */
+export type SidebarMenuSection = {
+  id: string;
+  label: string;
+  items: SidebarMenuItem[];
+};
+
+/** 즐겨찾기 항목 — 별 아이콘 + 라벨 한 줄이다. */
+export type SidebarMenuFavorite = {
+  id: string;
+  label: string;
+  href: string;
+};
+
+export type SidebarMenu = {
+  favorites: SidebarMenuFavorite[];
+  sections: SidebarMenuSection[];
+};
Add a comment
List