Merge branch 'claude/franchise-management-category-search-removal-ca3eaf' into hub
@8c18534572582390c60b04fb54ce185cf249923a
+++ app/(protected)/(basic)/_actions.ts
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { unstable_rethrow } from 'next/navigation'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { markNotificationRead } from '@/lib/data/repositories/notification-repository'; | |
| 6 | + | |
| 7 | +export type NotificationActionResult = | |
| 8 | + | { status: 'success' } | |
| 9 | + | { status: 'error'; message: string }; | |
| 10 | + | |
| 11 | +export async function markNotificationReadAction( | |
| 12 | + id: string | |
| 13 | +): Promise<NotificationActionResult> { | |
| 14 | + await verifySession(); | |
| 15 | + | |
| 16 | + if (!id) { | |
| 17 | + return { status: 'error', message: '요청이 올바르지 않습니다.' }; | |
| 18 | + } | |
| 19 | + | |
| 20 | + try { | |
| 21 | + await markNotificationRead(id); | |
| 22 | + return { status: 'success' }; | |
| 23 | + } catch (error) { | |
| 24 | + // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. | |
| 25 | + unstable_rethrow(error); | |
| 26 | + return { | |
| 27 | + status: 'error', | |
| 28 | + message: | |
| 29 | + error instanceof Error && error.message | |
| 30 | + ? error.message | |
| 31 | + : '알림을 읽음 처리하지 못했습니다.', | |
| 32 | + }; | |
| 33 | + } | |
| 34 | +} |
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
... | ... | @@ -2,8 +2,10 @@ |
| 2 | 2 |
|
| 3 | 3 |
import Image from 'next/image'; |
| 4 | 4 |
import Link from 'next/link'; |
| 5 |
-import { useEffect, useRef, useState } from 'react';
|
|
| 5 |
+import { useRouter } from 'next/navigation';
|
|
| 6 |
+import { startTransition, useEffect, useOptimistic, useRef, useState } from 'react';
|
|
| 6 | 7 |
import { logout } from '@/app/(protected)/_actions';
|
| 8 |
+import { markNotificationReadAction } from '@/app/(protected)/(basic)/_actions';
|
|
| 7 | 9 |
import {
|
| 8 | 10 |
countUnread, |
| 9 | 11 |
resolveNotificationHref, |
... | ... | @@ -33,15 +35,33 @@ |
| 33 | 35 |
* - 검색·사용자 아이콘 버튼을 뺐다 — 연결할 기능이 없어 눌러도 아무 일이 없었고, 사용자 |
| 34 | 36 |
* 자리는 옆 프로필 pill과 겹쳤다. |
| 35 | 37 |
* |
| 36 |
- * 남은 알림 버튼도 연결할 기능이 아직 없어 `onAction` 없이 둔다 — 시안의 모양을 유지하려고 |
|
| 37 |
- * 비활성으로 만들지는 않았다. |
|
| 38 |
+ * 이슈: 알림 읽음은 낙관적으로 표시한 뒤 `router.refresh()`로 서버 값에 되맞춘다 — 백엔드 |
|
| 39 |
+ * PUT이 아직 행을 갱신하지 못해(notification-repository 참조) 새로 고치면 안 읽음으로 돌아온다. |
|
| 38 | 40 |
*/ |
| 39 | 41 |
export function AdminHeader({ adminName, notifications }: AdminHeaderProps) {
|
| 42 |
+ const router = useRouter(); |
|
| 40 | 43 |
const [isNotificationOpen, setIsNotificationOpen] = useState(false); |
| 41 | 44 |
const notificationRef = useRef<HTMLDivElement>(null); |
| 42 |
- const unreadCount = countUnread(notifications); |
|
| 45 |
+ const [optimisticNotifications, markReadOptimistically] = useOptimistic( |
|
| 46 |
+ notifications, |
|
| 47 |
+ (current, id: string) => |
|
| 48 |
+ current.map((item) => (item.id === id ? { ...item, isRead: true } : item))
|
|
| 49 |
+ ); |
|
| 50 |
+ const unreadCount = countUnread(optimisticNotifications); |
|
| 43 | 51 |
const [isMenuOpen, setIsMenuOpen] = useState(false); |
| 44 | 52 |
const profileRef = useRef<HTMLDivElement>(null); |
| 53 |
+ |
|
| 54 |
+ function openNotification(item: AdminNotification) {
|
|
| 55 |
+ setIsNotificationOpen(false); |
|
| 56 |
+ if (item.isRead) {
|
|
| 57 |
+ return; |
|
| 58 |
+ } |
|
| 59 |
+ startTransition(async () => {
|
|
| 60 |
+ markReadOptimistically(item.id); |
|
| 61 |
+ await markNotificationReadAction(item.id); |
|
| 62 |
+ router.refresh(); |
|
| 63 |
+ }); |
|
| 64 |
+ } |
|
| 45 | 65 |
|
| 46 | 66 |
// 바깥을 누르거나 Esc를 누르면 닫는다 — 열린 동안에만 듣는다. 프로필·알림이 같은 규칙이라 |
| 47 | 67 |
// 한 곳에서 처리하고, 하나를 열면 다른 하나는 닫는다. |
... | ... | @@ -108,11 +128,11 @@ |
| 108 | 128 |
|
| 109 | 129 |
{isNotificationOpen && (
|
| 110 | 130 |
<div className={styles.notifications} role="menu">
|
| 111 |
- {notifications.length === 0 ? (
|
|
| 131 |
+ {optimisticNotifications.length === 0 ? (
|
|
| 112 | 132 |
<p className={styles.notificationEmpty}>새 알림이 없습니다.</p>
|
| 113 | 133 |
) : ( |
| 114 | 134 |
<ul className={styles.notificationList}>
|
| 115 |
- {notifications.map((item) => {
|
|
| 135 |
+ {optimisticNotifications.map((item) => {
|
|
| 116 | 136 |
const href = resolveNotificationHref(item); |
| 117 | 137 |
const className = `${styles.notification} ${
|
| 118 | 138 |
item.isRead ? '' : styles.notificationUnread |
... | ... | @@ -134,7 +154,7 @@ |
| 134 | 154 |
href={href}
|
| 135 | 155 |
role="menuitem" |
| 136 | 156 |
className={`${className} ${styles.notificationLink}`}
|
| 137 |
- onClick={() => setIsNotificationOpen(false)}
|
|
| 157 |
+ onClick={() => openNotification(item)}
|
|
| 138 | 158 |
> |
| 139 | 159 |
{body}
|
| 140 | 160 |
</Link> |
--- lib/data/repositories/notification-repository.ts
+++ lib/data/repositories/notification-repository.ts
... | ... | @@ -7,7 +7,8 @@ |
| 7 | 7 |
* 관리자 알림 Repository. |
| 8 | 8 |
* |
| 9 | 9 |
* ``` |
| 10 |
- * GET /api/v1/common/ntcn/list 로그인한 사용자의 알림 전체(등록일시 최신순) |
|
| 10 |
+ * GET /api/v1/common/ntcn/list 로그인한 사용자의 알림 전체(등록일시 최신순) |
|
| 11 |
+ * PUT /api/v1/common/ntcn/{ntcnSn} 읽음 처리
|
|
| 11 | 12 |
* ``` |
| 12 | 13 |
* |
| 13 | 14 |
* 관리자 전용(`mngr`)이 아니라 공통 경로다 — 토큰의 주인이 누구인지로 대상이 갈리고, |
... | ... | @@ -16,14 +17,17 @@ |
| 16 | 17 |
* 지금 적재되는 알림은 「학습자가 1:1문의를 등록했다」 하나다(`CmmBbsServiceImpl`이 등록 시 |
| 17 | 18 |
* 관리자 전원에게 한 건씩 넣는다). |
| 18 | 19 |
* |
| 19 |
- * ⚠️ 읽음 처리(`PUT /{ntcnSn}`)는 붙이지 않았다 — 백엔드 컨트롤러가 경로변수 `ntcnSn`도
|
|
| 20 |
- * `readYn`도 VO에 담지 않아 어느 행도 갱신되지 않는다(보고함). 고쳐지면 여기에 함수를 더한다. |
|
| 20 |
+ * ⚠️ 읽음 처리(`PUT /{ntcnSn}`)는 **아직 아무것도 바꾸지 못한다** — 백엔드 컨트롤러가
|
|
| 21 |
+ * 경로변수 `ntcnSn`도 `readYn`도 VO에 담지 않아 `WHERE ntcn_sn = NULL`로 어느 행도 갱신되지 |
|
| 22 |
+ * 않고 200만 돌아온다(보고함). 호출은 규약대로 붙여 두었으니 백엔드가 두 줄 |
|
| 23 |
+ * (`setNtcnSn(ntcnSn)`·`setReadYn("Y")`)을 채우면 프론트 수정 없이 동작한다.
|
|
| 21 | 24 |
* |
| 22 | 25 |
* 페이징이 없다. 목록 SQL에 LIMIT이 없어 전체가 온다 — 알림이 쌓이면 백엔드에 상한을 |
| 23 | 26 |
* 요청해야 한다. |
| 24 | 27 |
*/ |
| 25 | 28 |
|
| 26 |
-const NOTIFICATION_LIST_PATH = '/api/v1/common/ntcn/list'; |
|
| 29 |
+const NOTIFICATION_PATH = '/api/v1/common/ntcn'; |
|
| 30 |
+const NOTIFICATION_LIST_PATH = `${NOTIFICATION_PATH}/list`;
|
|
| 27 | 31 |
|
| 28 | 32 |
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 29 | 33 |
return value !== null && typeof value === 'object'; |
... | ... | @@ -92,3 +96,21 @@ |
| 92 | 96 |
return []; |
| 93 | 97 |
} |
| 94 | 98 |
} |
| 99 |
+ |
|
| 100 |
+export async function markNotificationRead(id: string): Promise<void> {
|
|
| 101 |
+ const accessToken = await getSessionAccessToken(); |
|
| 102 |
+ |
|
| 103 |
+ const result = await backendFetch<null>( |
|
| 104 |
+ `${NOTIFICATION_PATH}/${encodeURIComponent(id)}`,
|
|
| 105 |
+ {
|
|
| 106 |
+ method: 'PUT', |
|
| 107 |
+ accessToken: accessToken ?? undefined, |
|
| 108 |
+ cache: 'no-store', |
|
| 109 |
+ canHaveNullData: true, |
|
| 110 |
+ } |
|
| 111 |
+ ); |
|
| 112 |
+ |
|
| 113 |
+ if (!result.ok) {
|
|
| 114 |
+ throw new BackendRequestError(result); |
|
| 115 |
+ } |
|
| 116 |
+} |
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?