임동욱 임동욱 09-03
feat: 상단바 종 버튼에 알림 목록을 드롭다운으로 붙인다
백엔드가 학습자의 1:1문의 등록 시 관리자 전원에게 알림을 적재하는데, 관리자 페이지에
그것을 볼 자리가 없었다. 종 버튼이 눌러도 아무 일이 없던 상태다.

목록은 레이아웃이 서버에서 조회해 내려보낸다 — 클라이언트가 나중에 부르면 첫 페인트에
비었다가 채워지고 토큰도 브라우저로 내려간다(사이드바 메뉴와 같은 이유).

조회가 실패해도 던지지 않는다. 헤더는 모든 화면에 붙어 있어 알림 하나 때문에 화면
전체가 죽으면 안 된다 — 못 가져오면 빈 목록이다.

읽음 처리는 붙이지 않았다. 백엔드 컨트롤러가 경로변수도 readYn도 VO에 담지 않아 어느
행도 갱신되지 않는다(보고함).

Co-Authored-By: Claude Opus 5 
@64d5b252399681d2686d943c19733d9ce577d2c8
app/(protected)/(basic)/_components/admin-header.module.scss
--- app/(protected)/(basic)/_components/admin-header.module.scss
+++ app/(protected)/(basic)/_components/admin-header.module.scss
@@ -67,11 +67,83 @@
 }
 
 .btnGroup {
+  // 알림 패널이 이 묶음 기준으로 떠야 해서 위치 기준을 만든다(프로필과 같은 처리).
+  position: relative;
   display: flex;
   align-items: center;
   gap: fox.gap(2);
 }
 
+// ── 알림 패널 ───────────────────────────────────────────────────────────────
+// 뜨는 모양은 프로필 메뉴와 같게 두고 폭·높이만 목록에 맞춘다.
+.notifications {
+  position: absolute;
+  inset-block-start: calc(100% + #{fox.gap(2)});
+  inset-inline-end: 0;
+  z-index: 1;
+  // 시안에 알림 패널이 없어 폭은 목록이 읽히는 선에서 잡았다(사용자 확정 전 잠정값).
+  inline-size: 32rem;
+  max-block-size: 40rem;
+  overflow-y: auto;
+  @include fox.scroll;
+  padding-block: fox.padding(2);
+  border: fox.border(1) solid fox.color(border-neutral-default);
+  border-radius: fox.form(radius-md);
+  background: fox.color(surface-neutral-default);
+  box-shadow: fox.shadow(location1) fox.shadow(location1) fox.shadow(blur1) 0
+    fox.primitive(alpha-black10);
+}
+
+.notificationList {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.notification {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(1);
+  padding-block: fox.padding(3);
+  padding-inline: fox.padding(4);
+}
+
+// 안 읽은 알림은 왼쪽에 표시선을 둔다 — 점을 따로 그리면 글자 시작 위치가 줄마다 어긋난다.
+.notificationUnread {
+  box-shadow: inset #{fox.border(2)} 0 0 0 fox.color(border-theme-primary);
+  background: fox.color(surface-theme-primary);
+}
+
+.notificationTitle {
+  color: fox.color(font-neutral-strong);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(label-md);
+  font-weight: fox.font-weight(medium);
+  line-height: 1.4;
+  letter-spacing: -0.025em;
+}
+
+.notificationContent {
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+  word-break: break-word;
+}
+
+.notificationEmpty {
+  margin: 0;
+  padding-block: fox.padding(5);
+  padding-inline: fox.padding(4);
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+  text-align: center;
+}
+
 // ── 프로필 ──────────────────────────────────────────────────────────────────
 // 메뉴가 이 버튼 기준으로 떠야 해서 위치 기준을 만든다.
 .profile {
app/(protected)/(basic)/_components/admin-header.tsx
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
@@ -3,12 +3,15 @@
 import Image from 'next/image';
 import { useEffect, useRef, useState } from 'react';
 import { logout } from '@/app/(protected)/_actions';
+import { countUnread, type AdminNotification } from '@/lib/domain/notification';
 import styles from './admin-header.module.scss';
 import { HeaderIconButton } from './header-icon-button';
 
 interface AdminHeaderProps {
   /** 프로필에 표시할 이름. 세션의 관리자 이름을 상위에서 받아온다. */
   adminName: string;
+  /** 종 버튼이 펼치는 알림. 상위 레이아웃이 조회해 넘긴다. */
+  notifications: AdminNotification[];
 }
 
 /**
@@ -28,25 +31,34 @@
  * 남은 알림 버튼도 연결할 기능이 아직 없어 `onAction` 없이 둔다 — 시안의 모양을 유지하려고
  * 비활성으로 만들지는 않았다.
  */
-export function AdminHeader({ adminName }: AdminHeaderProps) {
+export function AdminHeader({ adminName, notifications }: AdminHeaderProps) {
+  const [isNotificationOpen, setIsNotificationOpen] = useState(false);
+  const notificationRef = useRef<HTMLDivElement>(null);
+  const unreadCount = countUnread(notifications);
   const [isMenuOpen, setIsMenuOpen] = useState(false);
   const profileRef = useRef<HTMLDivElement>(null);
 
-  // 바깥을 누르거나 Esc를 누르면 닫는다 — 메뉴가 열린 동안에만 듣는다.
+  // 바깥을 누르거나 Esc를 누르면 닫는다 — 열린 동안에만 듣는다. 프로필·알림이 같은 규칙이라
+  // 한 곳에서 처리하고, 하나를 열면 다른 하나는 닫는다.
   useEffect(() => {
-    if (!isMenuOpen) {
+    if (!isMenuOpen && !isNotificationOpen) {
       return;
     }
 
     function handlePointerDown(event: MouseEvent) {
-      if (!profileRef.current?.contains(event.target as Node)) {
+      const target = event.target as Node;
+      if (!profileRef.current?.contains(target)) {
         setIsMenuOpen(false);
+      }
+      if (!notificationRef.current?.contains(target)) {
+        setIsNotificationOpen(false);
       }
     }
 
     function handleKeyDown(event: KeyboardEvent) {
       if (event.key === 'Escape') {
         setIsMenuOpen(false);
+        setIsNotificationOpen(false);
       }
     }
 
@@ -56,7 +68,7 @@
       document.removeEventListener('mousedown', handlePointerDown);
       document.removeEventListener('keydown', handleKeyDown);
     };
-  }, [isMenuOpen]);
+  }, [isMenuOpen, isNotificationOpen]);
 
   return (
     <header className={styles.header}>
@@ -77,8 +89,44 @@
       </div>
 
       <div className={styles.fnc}>
-        <div className={styles.btnGroup}>
-          <HeaderIconButton icon={<i className="fox-ico fox-ico-BellSimple" aria-hidden="true" />} label="알림" hasBadge />
+        <div className={styles.btnGroup} ref={notificationRef}>
+          <HeaderIconButton
+            icon={<i className="fox-ico fox-ico-BellSimple" aria-hidden="true" />}
+            label={unreadCount > 0 ? `알림 ${unreadCount}건` : '알림'}
+            hasBadge={unreadCount > 0}
+            isExpanded={isNotificationOpen}
+            onAction={() => {
+              setIsNotificationOpen((open) => !open);
+              setIsMenuOpen(false);
+            }}
+          />
+
+          {isNotificationOpen && (
+            <div className={styles.notifications} role="menu">
+              {notifications.length === 0 ? (
+                <p className={styles.notificationEmpty}>새 알림이 없습니다.</p>
+              ) : (
+                <ul className={styles.notificationList}>
+                  {notifications.map((item) => (
+                    <li key={item.id}>
+                      <div
+                        className={`${styles.notification} ${
+                          item.isRead ? '' : styles.notificationUnread
+                        }`}
+                      >
+                        <span className={styles.notificationTitle}>
+                          {item.title}
+                        </span>
+                        <span className={styles.notificationContent}>
+                          {item.content}
+                        </span>
+                      </div>
+                    </li>
+                  ))}
+                </ul>
+              )}
+            </div>
+          )}
         </div>
 
         <div className={styles.profile} ref={profileRef}>
app/(protected)/(basic)/_components/admin-shell.tsx
--- app/(protected)/(basic)/_components/admin-shell.tsx
+++ app/(protected)/(basic)/_components/admin-shell.tsx
@@ -1,5 +1,6 @@
 'use client';
 
+import type { AdminNotification } from '@/lib/domain/notification';
 import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
 import { AdminFooter } from './admin-footer';
 import styles from './admin-shell.module.scss';
@@ -12,6 +13,8 @@
   adminName: string;
   /** 계정 권한에 맞춰 서버가 구성한 사이드바 메뉴. */
   menu: SidebarMenu;
+  /** 헤더 종 버튼이 펼치는 알림 목록. */
+  notifications: AdminNotification[];
 }
 
 /**
@@ -23,10 +26,15 @@
  * 어디에도 그 토글이 없어 헤더 구현 때 함께 걷어냈다 — 접힘 모양이 시안에 정의되면 그때
  * 다시 붙인다.
  */
-export function AdminShell({ children, adminName, menu }: AdminShellProps) {
+export function AdminShell({
+  children,
+  adminName,
+  menu,
+  notifications,
+}: AdminShellProps) {
   return (
     <div className={styles.shell}>
-      <AdminHeader adminName={adminName} />
+      <AdminHeader adminName={adminName} notifications={notifications} />
       <div className={styles.body}>
         <AdminSidebar menu={menu} />
         <div className={styles.content}>
app/(protected)/(basic)/_components/header-icon-button.tsx
--- app/(protected)/(basic)/_components/header-icon-button.tsx
+++ app/(protected)/(basic)/_components/header-icon-button.tsx
@@ -9,6 +9,11 @@
   label: string;
   /** 참이면 우상단에 알림 점(FoxBadgeDot)을 얹는다. */
   hasBadge?: boolean;
+  /**
+   * 눌러서 여는 패널이 있으면 그 펼침 상태. FoxIconButton은 `aria-expanded`를 받지 않아
+   * 감싸는 `<span>`에 얹는다 — 버튼에 직접 주려면 @fox 공개 API를 넓혀야 한다.
+   */
+  isExpanded?: boolean;
   onAction?: () => void;
 }
 
@@ -23,10 +28,15 @@
   icon,
   label,
   hasBadge = false,
+  isExpanded,
   onAction,
 }: HeaderIconButtonProps) {
   return (
-    <span className={styles.slot}>
+    <span
+      className={styles.slot}
+      aria-haspopup={isExpanded === undefined ? undefined : 'menu'}
+      aria-expanded={isExpanded}
+    >
       <FoxIconButton
         type="ghost"
         size="md"
app/(protected)/(basic)/layout.tsx
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
@@ -1,4 +1,5 @@
 import { getSessionAdmin } from '@/lib/auth/dal';
+import { fetchNotifications } from '@/lib/data/repositories/notification-repository';
 import { fetchSidebarMenu } from '@/lib/data/repositories/sidebar-menu-repository';
 import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
 import { AdminShell } from './_components/admin-shell';
@@ -21,10 +22,18 @@
   children: React.ReactNode;
 }>) {
   const admin = await getSessionAdmin();
-  const menu = admin ? await fetchSidebarMenu(admin.roleCode) : EMPTY_MENU;
+  // 메뉴와 알림은 서로를 기다릴 이유가 없다.
+  const [menu, notifications] = await Promise.all([
+    admin ? fetchSidebarMenu(admin.roleCode) : EMPTY_MENU,
+    admin ? fetchNotifications() : [],
+  ]);
 
   return (
-    <AdminShell adminName={admin?.name ?? '관리자'} menu={menu}>
+    <AdminShell
+      adminName={admin?.name ?? '관리자'}
+      menu={menu}
+      notifications={notifications}
+    >
       {children}
     </AdminShell>
   );
 
lib/data/repositories/notification-repository.ts (added)
+++ lib/data/repositories/notification-repository.ts
@@ -0,0 +1,94 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type { AdminNotification } from '@/lib/domain/notification';
+
+/**
+ * 관리자 알림 Repository.
+ *
+ * ```
+ * GET /api/v1/common/ntcn/list   로그인한 사용자의 알림 전체(등록일시 최신순)
+ * ```
+ *
+ * 관리자 전용(`mngr`)이 아니라 공통 경로다 — 토큰의 주인이 누구인지로 대상이 갈리고,
+ * 인가는 "로그인했으면 통과"라 관리자 토큰으로도 조회된다(SecurityConfig 확인함).
+ *
+ * 지금 적재되는 알림은 「학습자가 1:1문의를 등록했다」 하나다(`CmmBbsServiceImpl`이 등록 시
+ * 관리자 전원에게 한 건씩 넣는다).
+ *
+ * ⚠️ 읽음 처리(`PUT /{ntcnSn}`)는 붙이지 않았다 — 백엔드 컨트롤러가 경로변수 `ntcnSn`도
+ * `readYn`도 VO에 담지 않아 어느 행도 갱신되지 않는다(보고함). 고쳐지면 여기에 함수를 더한다.
+ *
+ * 페이징이 없다. 목록 SQL에 LIMIT이 없어 전체가 온다 — 알림이 쌓이면 백엔드에 상한을
+ * 요청해야 한다.
+ */
+
+const NOTIFICATION_LIST_PATH = '/api/v1/common/ntcn/list';
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string {
+  const value = source[key];
+  return typeof value === 'string' ? value : '';
+}
+
+function toNotification(raw: unknown): AdminNotification | null {
+  if (!isRecord(raw)) {
+    return null;
+  }
+
+  // `ntcnSn`은 숫자로 올 수도 있다 — 키로만 쓰므로 문자열로 맞춘다.
+  const rawId = raw.ntcnSn;
+  const id =
+    typeof rawId === 'string'
+      ? rawId
+      : typeof rawId === 'number'
+        ? String(rawId)
+        : '';
+  if (!id) {
+    return null;
+  }
+
+  return {
+    id,
+    title: readString(raw, 'ntcnSj'),
+    content: readString(raw, 'ntcnCn'),
+    isRead: readString(raw, 'readYn') === 'Y',
+  };
+}
+
+/**
+ * 로그인한 관리자의 알림을 가져온다.
+ *
+ * **실패해도 던지지 않는다.** 헤더는 모든 화면에 붙어 있어, 알림 조회가 안 된다고 화면
+ * 전체가 죽으면 안 된다. 못 가져오면 빈 목록으로 둔다.
+ */
+export async function fetchNotifications(): Promise<AdminNotification[]> {
+  const accessToken = await getSessionAccessToken();
+  if (!accessToken) {
+    return [];
+  }
+
+  try {
+    const result = await backendFetch<unknown>(NOTIFICATION_LIST_PATH, {
+      method: 'GET',
+      accessToken,
+      cache: 'no-store',
+      canHaveNullData: true,
+    });
+
+    if (!result.ok) {
+      throw new BackendRequestError(result);
+    }
+
+    return Array.isArray(result.data)
+      ? result.data
+          .map(toNotification)
+          .filter((item): item is AdminNotification => item !== null)
+      : [];
+  } catch {
+    return [];
+  }
+}
 
lib/domain/notification.ts (added)
+++ lib/domain/notification.ts
@@ -0,0 +1,22 @@
+/**
+ * 관리자 알림 한 건. 백엔드 `CmmNtcnVo`(`TB_COM_NTCN`).
+ *
+ * 이름을 `Notification`으로 두지 않는다 — 브라우저 전역 타입과 겹쳐 클라이언트 코드에서
+ * 어느 쪽인지 흐려진다.
+ */
+export type AdminNotification = {
+  /** 백엔드 `ntcnSn` — 일련번호이자 키. */
+  id: string;
+  /** 백엔드 `ntcnSj` — 제목. */
+  title: string;
+  /** 백엔드 `ntcnCn` — 내용. */
+  content: string;
+  /** 백엔드 `readYn`. 값이 없으면 안 읽은 것으로 본다. */
+  isRead: boolean;
+};
+
+export function countUnread(
+  notifications: readonly AdminNotification[]
+): number {
+  return notifications.filter((item) => !item.isRead).length;
+}
Add a comment
List