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
... | ... | @@ -67,11 +67,83 @@ |
| 67 | 67 |
} |
| 68 | 68 |
|
| 69 | 69 |
.btnGroup {
|
| 70 |
+ // 알림 패널이 이 묶음 기준으로 떠야 해서 위치 기준을 만든다(프로필과 같은 처리). |
|
| 71 |
+ position: relative; |
|
| 70 | 72 |
display: flex; |
| 71 | 73 |
align-items: center; |
| 72 | 74 |
gap: fox.gap(2); |
| 73 | 75 |
} |
| 74 | 76 |
|
| 77 |
+// ── 알림 패널 ─────────────────────────────────────────────────────────────── |
|
| 78 |
+// 뜨는 모양은 프로필 메뉴와 같게 두고 폭·높이만 목록에 맞춘다. |
|
| 79 |
+.notifications {
|
|
| 80 |
+ position: absolute; |
|
| 81 |
+ inset-block-start: calc(100% + #{fox.gap(2)});
|
|
| 82 |
+ inset-inline-end: 0; |
|
| 83 |
+ z-index: 1; |
|
| 84 |
+ // 시안에 알림 패널이 없어 폭은 목록이 읽히는 선에서 잡았다(사용자 확정 전 잠정값). |
|
| 85 |
+ inline-size: 32rem; |
|
| 86 |
+ max-block-size: 40rem; |
|
| 87 |
+ overflow-y: auto; |
|
| 88 |
+ @include fox.scroll; |
|
| 89 |
+ padding-block: fox.padding(2); |
|
| 90 |
+ border: fox.border(1) solid fox.color(border-neutral-default); |
|
| 91 |
+ border-radius: fox.form(radius-md); |
|
| 92 |
+ background: fox.color(surface-neutral-default); |
|
| 93 |
+ box-shadow: fox.shadow(location1) fox.shadow(location1) fox.shadow(blur1) 0 |
|
| 94 |
+ fox.primitive(alpha-black10); |
|
| 95 |
+} |
|
| 96 |
+ |
|
| 97 |
+.notificationList {
|
|
| 98 |
+ margin: 0; |
|
| 99 |
+ padding: 0; |
|
| 100 |
+ list-style: none; |
|
| 101 |
+} |
|
| 102 |
+ |
|
| 103 |
+.notification {
|
|
| 104 |
+ display: flex; |
|
| 105 |
+ flex-direction: column; |
|
| 106 |
+ gap: fox.gap(1); |
|
| 107 |
+ padding-block: fox.padding(3); |
|
| 108 |
+ padding-inline: fox.padding(4); |
|
| 109 |
+} |
|
| 110 |
+ |
|
| 111 |
+// 안 읽은 알림은 왼쪽에 표시선을 둔다 — 점을 따로 그리면 글자 시작 위치가 줄마다 어긋난다. |
|
| 112 |
+.notificationUnread {
|
|
| 113 |
+ box-shadow: inset #{fox.border(2)} 0 0 0 fox.color(border-theme-primary);
|
|
| 114 |
+ background: fox.color(surface-theme-primary); |
|
| 115 |
+} |
|
| 116 |
+ |
|
| 117 |
+.notificationTitle {
|
|
| 118 |
+ color: fox.color(font-neutral-strong); |
|
| 119 |
+ font-family: fox.font-family(body); |
|
| 120 |
+ font-size: fox.font-size(label-md); |
|
| 121 |
+ font-weight: fox.font-weight(medium); |
|
| 122 |
+ line-height: 1.4; |
|
| 123 |
+ letter-spacing: -0.025em; |
|
| 124 |
+} |
|
| 125 |
+ |
|
| 126 |
+.notificationContent {
|
|
| 127 |
+ color: fox.color(font-neutral-subtle); |
|
| 128 |
+ font-family: fox.font-family(body); |
|
| 129 |
+ font-size: fox.font-size(body-sm); |
|
| 130 |
+ line-height: 1.5; |
|
| 131 |
+ letter-spacing: -0.025em; |
|
| 132 |
+ word-break: break-word; |
|
| 133 |
+} |
|
| 134 |
+ |
|
| 135 |
+.notificationEmpty {
|
|
| 136 |
+ margin: 0; |
|
| 137 |
+ padding-block: fox.padding(5); |
|
| 138 |
+ padding-inline: fox.padding(4); |
|
| 139 |
+ color: fox.color(font-neutral-subtle); |
|
| 140 |
+ font-family: fox.font-family(body); |
|
| 141 |
+ font-size: fox.font-size(body-sm); |
|
| 142 |
+ line-height: 1.5; |
|
| 143 |
+ letter-spacing: -0.025em; |
|
| 144 |
+ text-align: center; |
|
| 145 |
+} |
|
| 146 |
+ |
|
| 75 | 147 |
// ── 프로필 ────────────────────────────────────────────────────────────────── |
| 76 | 148 |
// 메뉴가 이 버튼 기준으로 떠야 해서 위치 기준을 만든다. |
| 77 | 149 |
.profile {
|
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
... | ... | @@ -3,12 +3,15 @@ |
| 3 | 3 |
import Image from 'next/image'; |
| 4 | 4 |
import { useEffect, useRef, useState } from 'react';
|
| 5 | 5 |
import { logout } from '@/app/(protected)/_actions';
|
| 6 |
+import { countUnread, type AdminNotification } from '@/lib/domain/notification';
|
|
| 6 | 7 |
import styles from './admin-header.module.scss'; |
| 7 | 8 |
import { HeaderIconButton } from './header-icon-button';
|
| 8 | 9 |
|
| 9 | 10 |
interface AdminHeaderProps {
|
| 10 | 11 |
/** 프로필에 표시할 이름. 세션의 관리자 이름을 상위에서 받아온다. */ |
| 11 | 12 |
adminName: string; |
| 13 |
+ /** 종 버튼이 펼치는 알림. 상위 레이아웃이 조회해 넘긴다. */ |
|
| 14 |
+ notifications: AdminNotification[]; |
|
| 12 | 15 |
} |
| 13 | 16 |
|
| 14 | 17 |
/** |
... | ... | @@ -28,25 +31,34 @@ |
| 28 | 31 |
* 남은 알림 버튼도 연결할 기능이 아직 없어 `onAction` 없이 둔다 — 시안의 모양을 유지하려고 |
| 29 | 32 |
* 비활성으로 만들지는 않았다. |
| 30 | 33 |
*/ |
| 31 |
-export function AdminHeader({ adminName }: AdminHeaderProps) {
|
|
| 34 |
+export function AdminHeader({ adminName, notifications }: AdminHeaderProps) {
|
|
| 35 |
+ const [isNotificationOpen, setIsNotificationOpen] = useState(false); |
|
| 36 |
+ const notificationRef = useRef<HTMLDivElement>(null); |
|
| 37 |
+ const unreadCount = countUnread(notifications); |
|
| 32 | 38 |
const [isMenuOpen, setIsMenuOpen] = useState(false); |
| 33 | 39 |
const profileRef = useRef<HTMLDivElement>(null); |
| 34 | 40 |
|
| 35 |
- // 바깥을 누르거나 Esc를 누르면 닫는다 — 메뉴가 열린 동안에만 듣는다. |
|
| 41 |
+ // 바깥을 누르거나 Esc를 누르면 닫는다 — 열린 동안에만 듣는다. 프로필·알림이 같은 규칙이라 |
|
| 42 |
+ // 한 곳에서 처리하고, 하나를 열면 다른 하나는 닫는다. |
|
| 36 | 43 |
useEffect(() => {
|
| 37 |
- if (!isMenuOpen) {
|
|
| 44 |
+ if (!isMenuOpen && !isNotificationOpen) {
|
|
| 38 | 45 |
return; |
| 39 | 46 |
} |
| 40 | 47 |
|
| 41 | 48 |
function handlePointerDown(event: MouseEvent) {
|
| 42 |
- if (!profileRef.current?.contains(event.target as Node)) {
|
|
| 49 |
+ const target = event.target as Node; |
|
| 50 |
+ if (!profileRef.current?.contains(target)) {
|
|
| 43 | 51 |
setIsMenuOpen(false); |
| 52 |
+ } |
|
| 53 |
+ if (!notificationRef.current?.contains(target)) {
|
|
| 54 |
+ setIsNotificationOpen(false); |
|
| 44 | 55 |
} |
| 45 | 56 |
} |
| 46 | 57 |
|
| 47 | 58 |
function handleKeyDown(event: KeyboardEvent) {
|
| 48 | 59 |
if (event.key === 'Escape') {
|
| 49 | 60 |
setIsMenuOpen(false); |
| 61 |
+ setIsNotificationOpen(false); |
|
| 50 | 62 |
} |
| 51 | 63 |
} |
| 52 | 64 |
|
... | ... | @@ -56,7 +68,7 @@ |
| 56 | 68 |
document.removeEventListener('mousedown', handlePointerDown);
|
| 57 | 69 |
document.removeEventListener('keydown', handleKeyDown);
|
| 58 | 70 |
}; |
| 59 |
- }, [isMenuOpen]); |
|
| 71 |
+ }, [isMenuOpen, isNotificationOpen]); |
|
| 60 | 72 |
|
| 61 | 73 |
return ( |
| 62 | 74 |
<header className={styles.header}>
|
... | ... | @@ -77,8 +89,44 @@ |
| 77 | 89 |
</div> |
| 78 | 90 |
|
| 79 | 91 |
<div className={styles.fnc}>
|
| 80 |
- <div className={styles.btnGroup}>
|
|
| 81 |
- <HeaderIconButton icon={<i className="fox-ico fox-ico-BellSimple" aria-hidden="true" />} label="알림" hasBadge />
|
|
| 92 |
+ <div className={styles.btnGroup} ref={notificationRef}>
|
|
| 93 |
+ <HeaderIconButton |
|
| 94 |
+ icon={<i className="fox-ico fox-ico-BellSimple" aria-hidden="true" />}
|
|
| 95 |
+ label={unreadCount > 0 ? `알림 ${unreadCount}건` : '알림'}
|
|
| 96 |
+ hasBadge={unreadCount > 0}
|
|
| 97 |
+ isExpanded={isNotificationOpen}
|
|
| 98 |
+ onAction={() => {
|
|
| 99 |
+ setIsNotificationOpen((open) => !open); |
|
| 100 |
+ setIsMenuOpen(false); |
|
| 101 |
+ }} |
|
| 102 |
+ /> |
|
| 103 |
+ |
|
| 104 |
+ {isNotificationOpen && (
|
|
| 105 |
+ <div className={styles.notifications} role="menu">
|
|
| 106 |
+ {notifications.length === 0 ? (
|
|
| 107 |
+ <p className={styles.notificationEmpty}>새 알림이 없습니다.</p>
|
|
| 108 |
+ ) : ( |
|
| 109 |
+ <ul className={styles.notificationList}>
|
|
| 110 |
+ {notifications.map((item) => (
|
|
| 111 |
+ <li key={item.id}>
|
|
| 112 |
+ <div |
|
| 113 |
+ className={`${styles.notification} ${
|
|
| 114 |
+ item.isRead ? '' : styles.notificationUnread |
|
| 115 |
+ }`} |
|
| 116 |
+ > |
|
| 117 |
+ <span className={styles.notificationTitle}>
|
|
| 118 |
+ {item.title}
|
|
| 119 |
+ </span> |
|
| 120 |
+ <span className={styles.notificationContent}>
|
|
| 121 |
+ {item.content}
|
|
| 122 |
+ </span> |
|
| 123 |
+ </div> |
|
| 124 |
+ </li> |
|
| 125 |
+ ))} |
|
| 126 |
+ </ul> |
|
| 127 |
+ )} |
|
| 128 |
+ </div> |
|
| 129 |
+ )} |
|
| 82 | 130 |
</div> |
| 83 | 131 |
|
| 84 | 132 |
<div className={styles.profile} ref={profileRef}>
|
--- app/(protected)/(basic)/_components/admin-shell.tsx
+++ app/(protected)/(basic)/_components/admin-shell.tsx
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
+import type { AdminNotification } from '@/lib/domain/notification';
|
|
| 3 | 4 |
import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
|
| 4 | 5 |
import { AdminFooter } from './admin-footer';
|
| 5 | 6 |
import styles from './admin-shell.module.scss'; |
... | ... | @@ -12,6 +13,8 @@ |
| 12 | 13 |
adminName: string; |
| 13 | 14 |
/** 계정 권한에 맞춰 서버가 구성한 사이드바 메뉴. */ |
| 14 | 15 |
menu: SidebarMenu; |
| 16 |
+ /** 헤더 종 버튼이 펼치는 알림 목록. */ |
|
| 17 |
+ notifications: AdminNotification[]; |
|
| 15 | 18 |
} |
| 16 | 19 |
|
| 17 | 20 |
/** |
... | ... | @@ -23,10 +26,15 @@ |
| 23 | 26 |
* 어디에도 그 토글이 없어 헤더 구현 때 함께 걷어냈다 — 접힘 모양이 시안에 정의되면 그때 |
| 24 | 27 |
* 다시 붙인다. |
| 25 | 28 |
*/ |
| 26 |
-export function AdminShell({ children, adminName, menu }: AdminShellProps) {
|
|
| 29 |
+export function AdminShell({
|
|
| 30 |
+ children, |
|
| 31 |
+ adminName, |
|
| 32 |
+ menu, |
|
| 33 |
+ notifications, |
|
| 34 |
+}: AdminShellProps) {
|
|
| 27 | 35 |
return ( |
| 28 | 36 |
<div className={styles.shell}>
|
| 29 |
- <AdminHeader adminName={adminName} />
|
|
| 37 |
+ <AdminHeader adminName={adminName} notifications={notifications} />
|
|
| 30 | 38 |
<div className={styles.body}>
|
| 31 | 39 |
<AdminSidebar menu={menu} />
|
| 32 | 40 |
<div className={styles.content}>
|
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 2 |
+import { fetchNotifications } from '@/lib/data/repositories/notification-repository';
|
|
| 2 | 3 |
import { fetchSidebarMenu } from '@/lib/data/repositories/sidebar-menu-repository';
|
| 3 | 4 |
import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
|
| 4 | 5 |
import { AdminShell } from './_components/admin-shell';
|
... | ... | @@ -21,10 +22,18 @@ |
| 21 | 22 |
children: React.ReactNode; |
| 22 | 23 |
}>) {
|
| 23 | 24 |
const admin = await getSessionAdmin(); |
| 24 |
- const menu = admin ? await fetchSidebarMenu(admin.roleCode) : EMPTY_MENU; |
|
| 25 |
+ // 메뉴와 알림은 서로를 기다릴 이유가 없다. |
|
| 26 |
+ const [menu, notifications] = await Promise.all([ |
|
| 27 |
+ admin ? fetchSidebarMenu(admin.roleCode) : EMPTY_MENU, |
|
| 28 |
+ admin ? fetchNotifications() : [], |
|
| 29 |
+ ]); |
|
| 25 | 30 |
|
| 26 | 31 |
return ( |
| 27 |
- <AdminShell adminName={admin?.name ?? '관리자'} menu={menu}>
|
|
| 32 |
+ <AdminShell |
|
| 33 |
+ adminName={admin?.name ?? '관리자'}
|
|
| 34 |
+ menu={menu}
|
|
| 35 |
+ notifications={notifications}
|
|
| 36 |
+ > |
|
| 28 | 37 |
{children}
|
| 29 | 38 |
</AdminShell> |
| 30 | 39 |
); |
+++ lib/data/repositories/notification-repository.ts
... | ... | @@ -0,0 +1,94 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 4 | +import type { AdminNotification } from '@/lib/domain/notification'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 관리자 알림 Repository. | |
| 8 | + * | |
| 9 | + * ``` | |
| 10 | + * GET /api/v1/common/ntcn/list 로그인한 사용자의 알림 전체(등록일시 최신순) | |
| 11 | + * ``` | |
| 12 | + * | |
| 13 | + * 관리자 전용(`mngr`)이 아니라 공통 경로다 — 토큰의 주인이 누구인지로 대상이 갈리고, | |
| 14 | + * 인가는 "로그인했으면 통과"라 관리자 토큰으로도 조회된다(SecurityConfig 확인함). | |
| 15 | + * | |
| 16 | + * 지금 적재되는 알림은 「학습자가 1:1문의를 등록했다」 하나다(`CmmBbsServiceImpl`이 등록 시 | |
| 17 | + * 관리자 전원에게 한 건씩 넣는다). | |
| 18 | + * | |
| 19 | + * ⚠️ 읽음 처리(`PUT /{ntcnSn}`)는 붙이지 않았다 — 백엔드 컨트롤러가 경로변수 `ntcnSn`도 | |
| 20 | + * `readYn`도 VO에 담지 않아 어느 행도 갱신되지 않는다(보고함). 고쳐지면 여기에 함수를 더한다. | |
| 21 | + * | |
| 22 | + * 페이징이 없다. 목록 SQL에 LIMIT이 없어 전체가 온다 — 알림이 쌓이면 백엔드에 상한을 | |
| 23 | + * 요청해야 한다. | |
| 24 | + */ | |
| 25 | + | |
| 26 | +const NOTIFICATION_LIST_PATH = '/api/v1/common/ntcn/list'; | |
| 27 | + | |
| 28 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 29 | + return value !== null && typeof value === 'object'; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function readString(source: Record<string, unknown>, key: string): string { | |
| 33 | + const value = source[key]; | |
| 34 | + return typeof value === 'string' ? value : ''; | |
| 35 | +} | |
| 36 | + | |
| 37 | +function toNotification(raw: unknown): AdminNotification | null { | |
| 38 | + if (!isRecord(raw)) { | |
| 39 | + return null; | |
| 40 | + } | |
| 41 | + | |
| 42 | + // `ntcnSn`은 숫자로 올 수도 있다 — 키로만 쓰므로 문자열로 맞춘다. | |
| 43 | + const rawId = raw.ntcnSn; | |
| 44 | + const id = | |
| 45 | + typeof rawId === 'string' | |
| 46 | + ? rawId | |
| 47 | + : typeof rawId === 'number' | |
| 48 | + ? String(rawId) | |
| 49 | + : ''; | |
| 50 | + if (!id) { | |
| 51 | + return null; | |
| 52 | + } | |
| 53 | + | |
| 54 | + return { | |
| 55 | + id, | |
| 56 | + title: readString(raw, 'ntcnSj'), | |
| 57 | + content: readString(raw, 'ntcnCn'), | |
| 58 | + isRead: readString(raw, 'readYn') === 'Y', | |
| 59 | + }; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** | |
| 63 | + * 로그인한 관리자의 알림을 가져온다. | |
| 64 | + * | |
| 65 | + * **실패해도 던지지 않는다.** 헤더는 모든 화면에 붙어 있어, 알림 조회가 안 된다고 화면 | |
| 66 | + * 전체가 죽으면 안 된다. 못 가져오면 빈 목록으로 둔다. | |
| 67 | + */ | |
| 68 | +export async function fetchNotifications(): Promise<AdminNotification[]> { | |
| 69 | + const accessToken = await getSessionAccessToken(); | |
| 70 | + if (!accessToken) { | |
| 71 | + return []; | |
| 72 | + } | |
| 73 | + | |
| 74 | + try { | |
| 75 | + const result = await backendFetch<unknown>(NOTIFICATION_LIST_PATH, { | |
| 76 | + method: 'GET', | |
| 77 | + accessToken, | |
| 78 | + cache: 'no-store', | |
| 79 | + canHaveNullData: true, | |
| 80 | + }); | |
| 81 | + | |
| 82 | + if (!result.ok) { | |
| 83 | + throw new BackendRequestError(result); | |
| 84 | + } | |
| 85 | + | |
| 86 | + return Array.isArray(result.data) | |
| 87 | + ? result.data | |
| 88 | + .map(toNotification) | |
| 89 | + .filter((item): item is AdminNotification => item !== null) | |
| 90 | + : []; | |
| 91 | + } catch { | |
| 92 | + return []; | |
| 93 | + } | |
| 94 | +} |
+++ lib/domain/notification.ts
... | ... | @@ -0,0 +1,22 @@ |
| 1 | +/** | |
| 2 | + * 관리자 알림 한 건. 백엔드 `CmmNtcnVo`(`TB_COM_NTCN`). | |
| 3 | + * | |
| 4 | + * 이름을 `Notification`으로 두지 않는다 — 브라우저 전역 타입과 겹쳐 클라이언트 코드에서 | |
| 5 | + * 어느 쪽인지 흐려진다. | |
| 6 | + */ | |
| 7 | +export type AdminNotification = { | |
| 8 | + /** 백엔드 `ntcnSn` — 일련번호이자 키. */ | |
| 9 | + id: string; | |
| 10 | + /** 백엔드 `ntcnSj` — 제목. */ | |
| 11 | + title: string; | |
| 12 | + /** 백엔드 `ntcnCn` — 내용. */ | |
| 13 | + content: string; | |
| 14 | + /** 백엔드 `readYn`. 값이 없으면 안 읽은 것으로 본다. */ | |
| 15 | + isRead: boolean; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export function countUnread( | |
| 19 | + notifications: readonly AdminNotification[] | |
| 20 | +): number { | |
| 21 | + return notifications.filter((item) => !item.isRead).length; | |
| 22 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?