From d350d8566885900ad804f97afc19f9c144263e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:57:37 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=97=AD=ED=95=A0=EB=A1=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=97=AC=EB=B6=80=20=ED=8C=90=EB=B3=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/api/index.ts | 1 + src/shared/api/token.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index ffa0319f..ad8b1f11 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -15,6 +15,7 @@ export { export { clearAccessToken, getAccessToken, + hasAdminRole, setAccessToken, subscribeTokens, } from "@/shared/api/token"; diff --git a/src/shared/api/token.ts b/src/shared/api/token.ts index 2670c033..ce781f59 100644 --- a/src/shared/api/token.ts +++ b/src/shared/api/token.ts @@ -37,6 +37,32 @@ export function clearAccessToken(): void { notifyTokenListeners(); } +// 서버는 토큰 payload의 roles 클레임에 역할을 싣는다 — 관리자는 ["ADMIN", "STUDENT"]다(stream-server AuthTokenIssuer). +// 관리자 전용 UI(알림 화면의 관리자 알림 탭)를 보일지만 정한다. 서명은 확인하지 않으므로 실제 권한은 서버가 +// /v1/admin/**에서 막는다. 역할이 바뀌면 다시 로그인해 새 토큰을 받아야 반영된다. +export function hasAdminRole(): boolean { + const payload = getAccessToken()?.split(".")[1]; + if (payload === undefined) { + return false; + } + try { + // JWT payload는 base64url이다 — atob이 읽는 base64로 바꾼다(패딩은 없어도 읽는다) + const claims: unknown = JSON.parse( + window.atob(payload.replace(/-/g, "+").replace(/_/g, "/")), + ); + return ( + typeof claims === "object" && + claims !== null && + "roles" in claims && + Array.isArray(claims.roles) && + claims.roles.includes("ADMIN") + ); + } catch (error) { + console.warn("액세스 토큰의 역할을 읽지 못했어요.", error); + return false; + } +} + // 토큰이 바뀌면 알린다 — 토큰이 사라지면 로그인 화면으로 보내는 가드(AuthEntryGate)가 구독한다. // 요청 코드(apiClient)가 라우터를 직접 부르지 않게 하려고 둔다(router → 화면 → entities → apiClient 순환 import). export function subscribeTokens(listener: TokenListener): () => void { From fa7a1de92ac5efff090c5a6d280c1f0dcd7386e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:57:37 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=ED=91=9C=EA=B8=B0=EC=9A=A9=20=EC=83=81=EB=8C=80=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=C2=B7=EC=98=A4=EC=A0=84=20=EC=98=A4=ED=9B=84?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/date.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/lib/date.ts b/src/lib/date.ts index ecf3fd8e..ceb375ef 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -89,3 +89,38 @@ export function formatDateTime(value: string, options: DateFormatOptions = {}) { const [date, time] = value.split("T"); return `${formatDate(date, options)} ${time.slice(0, 5)}`; } + +const MINUTE_MS = 60 * 1000; + +// 서버 일시가 지금부터 얼마나 지났는지 — 알림 목록의 시간 표기다. 1분이 안 됐으면 "방금 전", 1시간이 안 됐으면 "N분 전", +// 하루가 안 됐으면 "N시간 전", 7일이 안 됐으면 "N일 전", 그 뒤로는 "8월 10일"로 쓴다. +// 서버 일시는 오프셋 없는 KST라 기기 시간대 기준 일시로 읽는다(getTimeUntil과 같다). +export function formatTimeAgo(value: string, now: Date = new Date()) { + const [date, time] = value.split("T"); + const [year, month, day] = date.split("-").map(Number); + const [hour, minute] = time.split(":").map(Number); + const minutes = Math.floor( + (now.getTime() - new Date(year, month - 1, day, hour, minute).getTime()) / + MINUTE_MS, + ); + + if (minutes < 1) { + return "방금 전"; + } + if (minutes < 60) { + return `${minutes}분 전`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}시간 전`; + } + const days = Math.floor(hours / 24); + return days < 7 ? `${days}일 전` : formatMonthDay(value); +} + +// 일시("2026-08-10T14:30:00")의 시각 → "오후 2:30". 정오는 "오후 12:00", 자정은 "오전 12:00"이다 +export function formatMeridiemTime(value: string) { + const [hour, minute] = value.split("T")[1].split(":").map(Number); + const meridiem = hour < 12 ? "오전" : "오후"; + return `${meridiem} ${hour % 12 || 12}:${String(minute).padStart(2, "0")}`; +} From b460109dcd5e58d439c8844bd9f0524c46ba67a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:57:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=8C=80=EC=97=AC=C2=B7=EB=B0=98=EB=82=A9=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/notifications/billilge-rental-request.svg | 5 +++++ src/assets/icons/notifications/billilge-return-request.svg | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 src/assets/icons/notifications/billilge-rental-request.svg create mode 100644 src/assets/icons/notifications/billilge-return-request.svg diff --git a/src/assets/icons/notifications/billilge-rental-request.svg b/src/assets/icons/notifications/billilge-rental-request.svg new file mode 100644 index 00000000..c27126ea --- /dev/null +++ b/src/assets/icons/notifications/billilge-rental-request.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/notifications/billilge-return-request.svg b/src/assets/icons/notifications/billilge-return-request.svg new file mode 100644 index 00000000..5c22cd65 --- /dev/null +++ b/src/assets/icons/notifications/billilge-return-request.svg @@ -0,0 +1,5 @@ + + + + + From f28dc9e86206e85567d0cd0a0da098b05874301a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:57:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20API=20=EC=97=B0=EB=8F=99=EA=B3=BC=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=95=8C=EB=A6=BC=20=ED=83=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 11 +- .../notifications/NotificationsScreen.tsx | 64 +++- .../components/NotificationsAdminItem.tsx | 156 ++++++++++ .../components/NotificationsAdminList.tsx | 286 ++++++++++++++++++ .../components/NotificationsList.tsx | 66 +++- .../notifications/constants/notifications.ts | 110 +++++++ .../entities/billilge/billilgeQueries.ts | 37 +++ src/shared/entities/billilge/billilgeTypes.ts | 8 + .../notifications/notificationsKeys.ts | 1 + .../notifications/notificationsMock.ts | 50 --- .../notifications/notificationsQueries.ts | 162 +++++++++- .../notifications/notificationsTypes.ts | 27 +- 12 files changed, 890 insertions(+), 88 deletions(-) create mode 100644 src/features/notifications/components/NotificationsAdminItem.tsx create mode 100644 src/features/notifications/components/NotificationsAdminList.tsx delete mode 100644 src/shared/entities/notifications/notificationsMock.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index b50b05b1..7fd013ed 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -274,7 +274,7 @@ const routes = [ path: "/search", }, { - element: , + element: , // Figma 알림 화면에는 Bottom Nav가 없고(뒤로가기로 진입한 화면에 복귀) 배경이 흰 면이다 handle: { background: "normal", @@ -282,6 +282,15 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/notifications", }, + { + element: , + // 알림 화면의 관리자 알림 탭 — 같은 화면을 탭만 바꿔 그리고, 알림 화면과 같은 이유로 Bottom Nav 없이 흰 면이다 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/notifications/admin", + }, { element: , // 알림 화면의 설정 아이콘으로 들어가는 화면 — 알림 화면과 같은 이유로 Bottom Nav 없이 흰 면이다 diff --git a/src/features/notifications/NotificationsScreen.tsx b/src/features/notifications/NotificationsScreen.tsx index ac71d9af..31ae615c 100644 --- a/src/features/notifications/NotificationsScreen.tsx +++ b/src/features/notifications/NotificationsScreen.tsx @@ -1,22 +1,42 @@ -import { TopNavigationButton } from "@wanteddev/wds"; +import { + SegmentedControl, + SegmentedControlItem, + TopNavigationButton, +} from "@wanteddev/wds"; import { IconChevronLeft, IconSetting } from "@wanteddev/wds-icon"; import { Suspense } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import { HOME_PATH } from "@/features/home/constants/home"; +import NotificationsAdminList from "@/features/notifications/components/NotificationsAdminList"; import NotificationsList from "@/features/notifications/components/NotificationsList"; import NotificationsListSkeleton from "@/features/notifications/components/NotificationsListSkeleton"; -import { NOTIFICATIONS_SETTINGS_PATH } from "@/features/notifications/constants/notifications"; +import { + NOTIFICATIONS_PATH, + NOTIFICATIONS_SETTINGS_PATH, + NOTIFICATIONS_TAB_PATHS, + type NotificationsTab, +} from "@/features/notifications/constants/notifications"; import { useBackNavigation } from "@/hooks/useBackNavigation"; +import { hasAdminRole } from "@/shared/api"; + +interface NotificationsScreenProps { + /** 내 알림/관리자 알림 탭 — 탭마다 경로가 달라서 라우터가 정해 넘긴다 */ + tab: NotificationsTab; +} // Figma: 알림 (nodeId 3595:107100), 알림 empty state (3595:107112) // 홈·행사·게시판·빌릴게 헤더의 알림 아이콘이 이 화면으로 보낸다. 헤더는 데이터와 상관없어 바로 그리고, // 목록 영역만 Suspense로 감싼다. -function NotificationsScreen() { +// 관리자 알림 탭은 Figma 시안이 없어서 빌릴게·행사 화면의 Tool 영역(WDS Segmented Control small)을 그대로 +// 따랐다. 두 목록은 서버에서 커서를 따로 넘기는 별개 알림함이라, 한 스크롤에 섹션으로 쌓지 않고 토글로 나눈다. +function NotificationsScreen({ tab }: NotificationsScreenProps) { const navigate = useNavigate(); const goBack = useBackNavigation(HOME_PATH); + // 로그인 토큰의 역할로 정한다 — 이 화면은 로그인해야 들어오고, 토큰이 사라지면 가드가 로그인으로 보낸다 + const isAdmin = hasAdminRole(); useScreenHeader( , ); + // 관리자가 아니면 토글을 그리지 않고, 관리자 탭 경로로 들어와도 내 알림으로 돌린다 + if (tab === "admin" && !isAdmin) { + return ; + } + return ( - }> - - + <> + {isAdmin && ( + // 목록만 스크롤되도록 고정(shrink-0)한다. 바로 아래 안 읽은 알림의 파란 배경이 토글에 붙지 않게 8px 띄운다 +
+ + navigate(NOTIFICATIONS_TAB_PATHS[value as NotificationsTab], { + replace: true, + }) + } + size="small" + value={tab} + > + 내 알림 + + 관리자 알림 + + +
+ )} + + {/* 탭 전환은 라우터 이동(transition)이라 같은 경계면 새 탭 데이터를 받는 동안 이전 탭을 붙잡는다 — + key로 탭마다 다른 경계로 둔다(행사 목록과 같은 이유). 읽음 state도 탭마다 따로 시작한다. */} + } key={tab}> + {tab === "admin" ? : } + + ); } diff --git a/src/features/notifications/components/NotificationsAdminItem.tsx b/src/features/notifications/components/NotificationsAdminItem.tsx new file mode 100644 index 00000000..25ab62e2 --- /dev/null +++ b/src/features/notifications/components/NotificationsAdminItem.tsx @@ -0,0 +1,156 @@ +import { Button, Typography } from "@wanteddev/wds"; +import { useId } from "react"; + +export type NotificationsAdminAction = "reject" | "approve" | "complete"; + +interface NotificationsAdminItemProps { + icon: string; + label: string; + title: string; + /** 받으러 오는 시각 같은 보조 정보 — 없으면 줄을 그리지 않는다 */ + body: string | null; + timeLabel: string; + isRead: boolean; + /** 처리 버튼 — 없으면(학생이 신청을 취소한 알림) 사용자 알림처럼 행 전체를 눌러 읽는다 */ + action: { + /** 승인 전(review)엔 반려·승인, 승인 뒤(handover)엔 대여·반납 완료 버튼을 둔다 */ + step: "review" | "handover"; + completeLabel: string; + /** 서버에 보내는 중인 버튼 — 그 버튼은 로딩으로, 나머지는 막아 둔다 */ + pendingAction: NotificationsAdminAction | null; + onAction: (action: NotificationsAdminAction) => void; + } | null; + onRead: () => void; +} + +// 관리자 알림 항목 — Figma 시안이 없어 사용자 알림 항목(NotificationsItem, 3595:107106)의 아이콘·라벨·시간 줄과 +// 안 읽은 배경을 그대로 따르고, 문구 오른쪽에 WDS Button(small)으로 처리 버튼을 붙였다. +// 버튼이 있는 항목은 행 전체를 버튼으로 둘 수 없어서(버튼 안 버튼) div로 그리고, 버튼이 없는 취소 알림만 +// 사용자 알림처럼 행 전체를 눌러 읽는다. +function NotificationsAdminItem({ + icon, + label, + title, + body, + timeLabel, + isRead, + action, + onRead, +}: NotificationsAdminItemProps) { + // 스크린리더가 "승인"만 읽지 않고 어느 신청의 버튼인지 같이 읽게 한다 + const titleId = useId(); + + const className = `flex w-full flex-col px-5 py-4 text-left ${ + isRead ? "" : "bg-notification-unread" + }`; + + const header = ( +
+
+ + + {label} + +
+ + {timeLabel} + +
+ ); + + // 버튼 때문에 문구 폭이 좁아서 "신청\n했어요"처럼 단어 중간에서 줄이 바뀌지 않게 단어 단위로 줄을 바꾼다 + const message = ( +
+ + {!isRead && 안 읽은 알림. } + {title} + + {body !== null && ( + + {body} + + )} +
+ ); + + if (action === null) { + return ( + + ); + } + + const { step, completeLabel, pendingAction, onAction } = action; + const isPending = pendingAction !== null; + + return ( +
+ {header} +
+ {message} +
+ {step === "handover" ? ( + + ) : ( + <> + + + + )} +
+
+
+ ); +} + +export default NotificationsAdminItem; diff --git a/src/features/notifications/components/NotificationsAdminList.tsx b/src/features/notifications/components/NotificationsAdminList.tsx new file mode 100644 index 00000000..42e9b91d --- /dev/null +++ b/src/features/notifications/components/NotificationsAdminList.tsx @@ -0,0 +1,286 @@ +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import emptyNotificationsIllustration from "@/assets/icons/notifications/empty-notifications.svg"; +import ConfirmModal from "@/components/ui/ConfirmModal"; +import EmptyState from "@/components/ui/EmptyState"; +import ScreenToast from "@/components/ui/ScreenToast"; +import NotificationsAdminItem, { + type NotificationsAdminAction, +} from "@/features/notifications/components/NotificationsAdminItem"; +import { + ADMIN_NOTIFICATION_TOASTS, + ADMIN_NOTIFICATION_TYPE_META, +} from "@/features/notifications/constants/notifications"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; +import { formatMeridiemTime, formatMonthDay, formatTimeAgo } from "@/lib/date"; +import { isApiError } from "@/shared/api"; +import { + BILLILGE_ERROR_CODES, + useUpdateAdminRentalStatusMutation, +} from "@/shared/entities/billilge/billilgeQueries"; +import type { BillilgeRentalStatus } from "@/shared/entities/billilge/billilgeTypes"; +import { + notificationsQueries, + useMarkAdminNotificationReadMutation, +} from "@/shared/entities/notifications/notificationsQueries"; +import type { AdminNotificationListItem } from "@/shared/entities/notifications/notificationsTypes"; + +type AdminNotificationStep = "review" | "handover"; + +// 대여 기록의 지금 상태로 버튼 단계를 고른다 — 승인 전이면 반려·승인, 승인 뒤면 완료. 그 밖의 상태(대여 중·반려· +// 취소·반납 완료 등)거나 대여 기록이 지워졌으면(rental null) 처리가 끝나 null이다. +function getStep( + notification: AdminNotificationListItem, +): AdminNotificationStep | null { + const action = ADMIN_NOTIFICATION_TYPE_META[notification.type]?.action; + const status = notification.rental?.rentalStatus; + if (!action || !status) { + return null; + } + if (status === action.reviewStatus) { + return "review"; + } + return status === action.handoverStatus ? "handover" : null; +} + +// 처리할 일만 남긴다 — 대여·반납 신청은 처리가 끝나기 전까지, 학생이 신청을 취소한 알림은 읽기 전까지만 보인다. +// 화면이 모르는 알림 종류는 그리지 않는다. +function isVisible(notification: AdminNotificationListItem) { + const meta = ADMIN_NOTIFICATION_TYPE_META[notification.type]; + if (!meta) { + return false; + } + return meta.action === null + ? !notification.read + : getStep(notification) !== null; +} + +// 보내는 중인 상태 변경이 어느 버튼에서 왔는지 — 같은 종류 안에서 버튼마다 바꿀 상태가 다르다 +function getPendingAction( + action: { approveTo: BillilgeRentalStatus; rejectTo: BillilgeRentalStatus }, + rentalStatus: BillilgeRentalStatus, +): NotificationsAdminAction { + if (rentalStatus === action.approveTo) { + return "approve"; + } + return rentalStatus === action.rejectTo ? "reject" : "complete"; +} + +// "YYYY-MM-DD" — 받으러 오는 날이 오늘인지 볼 때 쓴다 +function getTodayDate(now: Date = new Date()) { + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${now.getFullYear()}-${month}-${day}`; +} + +// 받으러 오는 시각 — 오늘이면 "오후 2:30", 다른 날이면 "11월 6일 오후 2:30" +function formatPickupTime(rentAt: string) { + const time = formatMeridiemTime(rentAt); + return rentAt.startsWith(getTodayDate()) + ? time + : `${formatMonthDay(rentAt)} ${time}`; +} + +// 문구는 대여 기록의 지금 값(rental)으로 만든다. 서버 title은 푸시 문구라 "김스트림(20231234) 님이 14:30에 우산 대여를 +// 신청했어요." 한 문장인데, 화면은 누가 무엇을 했는지만 제목에 두고 받으러 오는 시각은 둘째 줄로 내린다. +// 대여 기록이 지워진 취소 알림만 rental이 없어서 서버 문구를 그대로 쓴다. 수량은 2개 이상일 때만 붙인다. +function getCopy(notification: AdminNotificationListItem) { + const { rental } = notification; + const action = ADMIN_NOTIFICATION_TYPE_META[notification.type]?.action; + if (!rental) { + return { body: notification.body, title: notification.title }; + } + const item = `${rental.itemName ?? "삭제된 물품"}${ + rental.rentedCount > 1 ? ` ${rental.rentedCount}개` : "" + }`; + if (!action) { + return { + body: null, + title: `${rental.memberName} 님이 ${item} 대여 신청을 취소했어요`, + }; + } + return { + body: + action.showsPickupTime && rental.rentAt + ? `${formatPickupTime(rental.rentAt)}에 받으러 와요` + : null, + title: `${rental.memberName} 님이 ${item} ${action.appliedText}`, + }; +} + +// 관리자 공용 알림함 목록 — 처리할 일만 남는 목록이다. 버튼 단계와 숨김은 서버가 실어 주는 대여 기록의 지금 상태 +// (rental.rentalStatus)로 고르므로, 다른 관리자가 처리한 결과도 다시 받으면 그대로 반영된다. +// 서버는 처리가 끝난 알림까지 모두 주므로 한 페이지에 보일 게 적을 수 있다 — 끝 요소가 보이는 동안 다음 페이지를 이어 받는다. +function NotificationsAdminList() { + const { + data: notifications, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + } = useSuspenseInfiniteQuery({ + ...notificationsQueries.adminList(), + select: (data) => data.pages.flatMap((page) => page.content), + }); + const nextPageTriggerRef = useInfiniteScroll({ + fetchNextPage, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + }); + const { mutate: markRead } = useMarkAdminNotificationReadMutation(); + const statusMutation = useUpdateAdminRentalStatusMutation(); + // 모달이 닫히는 동안에도 문구가 남아 있도록 대상은 지우지 않고 열림만 따로 둔다 + const [rejectTarget, setRejectTarget] = + useState(null); + const [isRejectOpen, setIsRejectOpen] = useState(false); + const [toastMessage, setToastMessage] = useState( + ADMIN_NOTIFICATION_TOASTS.failed, + ); + const [toastOpen, setToastOpen] = useState(false); + // 토스트가 떠 있는 중에 다시 실패해도 스크린리더가 재안내하도록 매번 새로 마운트한다(PhoneChangeScreen과 같은 패턴) + const [toastToken, setToastToken] = useState(0); + + const showToast = (message: string) => { + setToastMessage(message); + setToastOpen(true); + setToastToken((token) => token + 1); + }; + + // 화면에서 본 상태(expectedStatus)를 같이 보내서, 그사이 다른 관리자가 처리했으면 서버가 거절한다. + // 처음 처리하는 알림은 읽음도 같이 보낸다 — 관리자 알림의 읽음은 관리자 전체가 공유한다. + const changeStatus = ( + notification: AdminNotificationListItem, + rentalStatus: BillilgeRentalStatus, + ) => { + const { rental } = notification; + if (!rental) { + return; + } + if (!notification.read) { + markRead(notification.notificationId); + } + statusMutation.mutate( + { + expectedStatus: rental.rentalStatus, + rentalHistoryId: rental.rentalHistoryId, + rentalStatus, + }, + { + onError: (error) => { + const isStatusChanged = + isApiError(error) && + error.code === BILLILGE_ERROR_CODES.rentalStatusChanged; + if (!isStatusChanged) { + console.error(error); + } + showToast( + isStatusChanged + ? ADMIN_NOTIFICATION_TOASTS.statusChanged + : ADMIN_NOTIFICATION_TOASTS.failed, + ); + }, + }, + ); + }; + + const visibleNotifications = notifications.filter(isVisible); + const rejectAction = rejectTarget + ? ADMIN_NOTIFICATION_TYPE_META[rejectTarget.type]?.action + : null; + + return ( + <> + {visibleNotifications.length === 0 && !hasNextPage ? ( + // 사용자 알림 빈 화면과 같은 자리 — 헤더(56px)만큼 올려서 프레임 가운데에 맞춘다 +
+ +
+ ) : ( +
+ {visibleNotifications.map((notification) => { + const meta = ADMIN_NOTIFICATION_TYPE_META[notification.type]; + const step = getStep(notification); + const { title, body } = getCopy(notification); + const { variables } = statusMutation; + const pendingAction = + statusMutation.isPending && + meta.action && + variables && + variables.rentalHistoryId === notification.rental?.rentalHistoryId + ? getPendingAction(meta.action, variables.rentalStatus) + : null; + + return ( + { + if (!meta.action) { + return; + } + if (nextAction === "reject") { + setRejectTarget(notification); + setIsRejectOpen(true); + return; + } + changeStatus( + notification, + nextAction === "approve" + ? meta.action.approveTo + : meta.action.completeTo, + ); + }, + pendingAction, + step, + } + : null + } + body={body} + icon={meta.icon} + isRead={notification.read} + key={notification.notificationId} + label={meta.label} + onRead={() => markRead(notification.notificationId)} + timeLabel={formatTimeAgo(notification.createdAt)} + title={title} + /> + ); + })} +
+
+ )} + + setIsRejectOpen(false)} + onConfirm={() => { + if (rejectTarget && rejectAction) { + changeStatus(rejectTarget, rejectAction.rejectTo); + } + setIsRejectOpen(false); + }} + open={isRejectOpen} + title={rejectAction?.rejectTitle ?? ""} + tone="negative" + /> + + + ); +} + +export default NotificationsAdminList; diff --git a/src/features/notifications/components/NotificationsList.tsx b/src/features/notifications/components/NotificationsList.tsx index 998a0bfc..8a7dc4c9 100644 --- a/src/features/notifications/components/NotificationsList.tsx +++ b/src/features/notifications/components/NotificationsList.tsx @@ -1,17 +1,44 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; + import emptyNotificationsIllustration from "@/assets/icons/notifications/empty-notifications.svg"; import EmptyState from "@/components/ui/EmptyState"; import NotificationsItem from "@/features/notifications/components/NotificationsItem"; -import { notificationsQueries } from "@/shared/entities/notifications/notificationsQueries"; +import { NOTIFICATION_SERVER_TYPES } from "@/features/notifications/constants/notifications"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; +import { formatTimeAgo } from "@/lib/date"; +import { + notificationsQueries, + useMarkNotificationReadMutation, +} from "@/shared/entities/notifications/notificationsQueries"; -// 알림 목록 데이터를 useSuspenseQuery로 읽는 데이터 컴포넌트 — 응답을 기다리는 동안은 바깥 Suspense가 스켈레톤을 보여준다. -// 읽음 처리는 서버 API가 없어서 이 화면 state로만 들고 있다(새로고침하면 목데이터 값으로 돌아간다). +// 내 알림 목록을 useSuspenseInfiniteQuery로 이어 받는 데이터 컴포넌트 — 첫 페이지를 기다리는 동안은 바깥 Suspense가 +// 스켈레톤을 보여준다. 화면이 모르는 알림 종류는 그리지 않는다. +// 서버 알림은 제목과 본문으로 나뉘어 오지만 Figma 항목은 한 덩어리 문구라 한 줄로 이어 붙인다. function NotificationsList() { - const { data: notifications } = useSuspenseQuery(notificationsQueries.list()); - const [readIds, setReadIds] = useState>(new Set()); + const { + data: notifications, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + } = useSuspenseInfiniteQuery({ + ...notificationsQueries.list(), + select: (data) => data.pages.flatMap((page) => page.content), + }); + const nextPageTriggerRef = useInfiniteScroll({ + fetchNextPage, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + }); + const { mutate: markRead } = useMarkNotificationReadMutation(); + + const visibleNotifications = notifications.filter( + (notification) => NOTIFICATION_SERVER_TYPES[notification.type], + ); - if (notifications.length === 0) { + // 받은 페이지에 그릴 알림이 없어도 다음 페이지가 있으면 끝 요소가 보여서 이어 받는다 — 빈 화면은 끝까지 없을 때만 + if (visibleNotifications.length === 0 && !hasNextPage) { // Figma는 Empty State를 프레임 전체 높이 기준 가운데에 둔다 — 헤더(56px)만큼 올려서 맞춘다. return (
@@ -26,18 +53,25 @@ function NotificationsList() { return (
- {notifications.map((notification) => ( + {visibleNotifications.map((notification) => ( - setReadIds((prev) => new Set(prev).add(notification.id)) + isRead={notification.read} + key={notification.notificationId} + message={ + notification.body === null + ? notification.title + : `${notification.title} ${notification.body}` } - timeLabel={notification.timeLabel} - type={notification.type} + onRead={() => { + if (!notification.read) { + markRead(notification.notificationId); + } + }} + timeLabel={formatTimeAgo(notification.createdAt)} + type={NOTIFICATION_SERVER_TYPES[notification.type]} /> ))} +
); } diff --git a/src/features/notifications/constants/notifications.ts b/src/features/notifications/constants/notifications.ts index 4d588f57..7f216be0 100644 --- a/src/features/notifications/constants/notifications.ts +++ b/src/features/notifications/constants/notifications.ts @@ -1,9 +1,12 @@ import billilgeRentalIcon from "@/assets/icons/notifications/billilge-rental.svg"; +import billilgeRentalRequestIcon from "@/assets/icons/notifications/billilge-rental-request.svg"; import billilgeReturnIcon from "@/assets/icons/notifications/billilge-return.svg"; +import billilgeReturnRequestIcon from "@/assets/icons/notifications/billilge-return-request.svg"; import eventIcon from "@/assets/icons/notifications/event.svg"; import feedbackIcon from "@/assets/icons/notifications/feedback.svg"; import lockerIcon from "@/assets/icons/notifications/locker.svg"; import noticeIcon from "@/assets/icons/notifications/notice.svg"; +import type { BillilgeRentalStatus } from "@/shared/entities/billilge/billilgeTypes"; import type { NotificationType } from "@/shared/entities/notifications/notificationsTypes"; // Figma Notification Category — 24px 아이콘 + 라벨. 빌릴게는 대여·반납이 같은 라벨에 아이콘만 다르다. @@ -16,6 +19,30 @@ export const NOTIFICATION_TYPE_META = { notices: { icon: noticeIcon, label: "공지" }, } satisfies Record; +// 서버 알림 종류(NotificationType 이름) → 화면 알림 종류. 반납 기한 알림(RENTAL_DUE_SOON·RENTAL_OVERDUE)은 +// 반납하라는 알림이라 반납 아이콘을 쓴다. 여기 없는 종류(새로 생긴 종류)는 목록에서 뺀다. +// 열린피드백 답변 알림은 서버에 아직 종류가 없어서 feedbacks는 연결된 서버 종류가 없다. +export const NOTIFICATION_SERVER_TYPES: Record = { + EVENT_APPLIED: "events", + EVENT_APPLICATION_CANCELED: "events", + EVENT_STARTING_TOMORROW: "events", + EVENT_APPLY_CLOSING: "events", + LOCKER_ASSIGNED: "lockers", + LOCKER_ASSIGNED_BY_ADMIN: "lockers", + LOCKER_ASSIGNMENT_CANCELED: "lockers", + LOCKER_APPLY_CLOSING: "lockers", + LOCKER_USAGE_ENDING: "lockers", + RENTAL_APPLY: "billilgeRental", + RENTAL_APPROVED: "billilgeRental", + RENTAL_REJECTED: "billilgeRental", + RENTAL_DUE_SOON: "billilgeReturn", + RENTAL_OVERDUE: "billilgeReturn", + RETURN_APPLY: "billilgeReturn", + RETURN_APPROVED: "billilgeReturn", + RETURN_COMPLETED: "billilgeReturn", + NOTICE_CREATED: "notices", +}; + // 알림 설정 카테고리 — Figma 알림 설정(nodeId 3628:110837)의 행 순서다. 게시판은 공지·열린피드백을 같이 켜고 끈다. export type NotificationSettingKey = | "billilge" @@ -63,3 +90,86 @@ export const INITIAL_NOTIFICATION_SETTINGS: Record< export const NOTIFICATIONS_PATH = "/notifications"; export const NOTIFICATIONS_SETTINGS_PATH = "/notifications/settings"; + +// 알림함은 내 알림과 관리자 공용 알림함(관리자에게만 보인다)으로 나뉜다. 빌릴게·행사 탭처럼 경로로 나눠서 +// 알림 설정에 다녀오거나 새로고침해도 탭이 유지되고, 관리자 업무 알림 푸시도 관리자 탭으로 바로 보낼 수 있다. +export const NOTIFICATIONS_TAB_PATHS = { + mine: NOTIFICATIONS_PATH, + admin: "/notifications/admin", +} as const; + +export type NotificationsTab = keyof typeof NOTIFICATIONS_TAB_PATHS; + +interface AdminNotificationAction { + /** 문구 뒷부분 — "김스트림 님이 우산 대여를 신청했어요" */ + appliedText: string; + /** 둘째 줄에 받으러 오는 시각을 쓰는지 — 대여 신청만 학생이 고른 수령 시각이 있다 */ + showsPickupTime: boolean; + /** 반려·승인 버튼을 두는 상태(승인 전)와 완료 버튼을 두는 상태(승인 후). 나머지 상태면 처리가 끝나 숨긴다 */ + reviewStatus: BillilgeRentalStatus; + handoverStatus: BillilgeRentalStatus; + /** 버튼마다 바꿀 상태 — 반납 반려는 대응하는 상태가 없어서 대여 중(RENTAL)으로 되돌린다 */ + approveTo: BillilgeRentalStatus; + rejectTo: BillilgeRentalStatus; + completeTo: BillilgeRentalStatus; + /** 승인한 뒤 물품을 건네거나 돌려받았을 때 누르는 버튼 */ + completeLabel: string; + /** 반려 확인 모달 — 반려하면 어떻게 되는지 미리 알려 준다 */ + rejectTitle: string; + rejectDescription: string; +} + +// 관리자 알림 종류별(서버 NotificationType 이름) 아이콘·라벨과 처리 방법. 사용자 알림처럼 빌릴게 라벨을 쓰고, +// 대여·반납 신청만 반려 → 승인 → 완료 순서로 처리한다. 학생이 신청을 취소한 알림은 처리할 일이 없어 action이 없다. +// 대여 반려는 서버가 학생에게 "대여 신청이 반려되었어요" 알림(RENTAL_REJECTED)을 보내고, 반납 반려는 알림이 없다. +// 대여·반납 신청 아이콘은 Figma에 없어서 빌릴게 알림 박스(billilge-return.svg, 진파랑 #0066FF)의 손잡이 자리에 +// 흰 화살표를 넣어 만들었다 — 꺼내 가는 대여는 위, 돌려놓는 반납은 아래다. 취소 알림은 화살표 없는 연한 박스 그대로다. +export const ADMIN_NOTIFICATION_TYPE_META: Record< + string, + { action: AdminNotificationAction | null; icon: string; label: string } +> = { + ADMIN_RENTAL_APPLY: { + action: { + approveTo: "CONFIRMED", + completeLabel: "대여 완료", + completeTo: "RENTAL", + appliedText: "대여를 신청했어요", + handoverStatus: "CONFIRMED", + rejectDescription: "반려하면 신청한 학생에게 알림이 가요.", + rejectTitle: "대여 신청을 반려할까요?", + rejectTo: "REJECTED", + reviewStatus: "PENDING", + showsPickupTime: true, + }, + icon: billilgeRentalRequestIcon, + label: "빌릴게", + }, + ADMIN_RETURN_APPLY: { + action: { + approveTo: "RETURN_CONFIRMED", + completeLabel: "반납 완료", + completeTo: "RETURNED", + appliedText: "반납을 신청했어요", + handoverStatus: "RETURN_CONFIRMED", + rejectDescription: "반려하면 다시 대여 중인 상태로 돌아가요.", + rejectTitle: "반납 신청을 반려할까요?", + rejectTo: "RENTAL", + reviewStatus: "RETURN_PENDING", + showsPickupTime: false, + }, + icon: billilgeReturnRequestIcon, + label: "빌릴게", + }, + ADMIN_RENTAL_CANCEL: { + action: null, + icon: billilgeRentalIcon, + label: "빌릴게", + }, +}; + +// 관리자 알림 처리 결과 안내 +export const ADMIN_NOTIFICATION_TOASTS = { + // 다른 관리자가 먼저 처리했거나 옛 화면에서 눌렀다(RENTAL_STATUS_CHANGED) — 목록은 다시 받는다 + statusChanged: "다른 관리자가 먼저 처리했어요.", + failed: "처리하지 못했어요. 잠시 후 다시 시도해 주세요.", +} as const; diff --git a/src/shared/entities/billilge/billilgeQueries.ts b/src/shared/entities/billilge/billilgeQueries.ts index f5768b3d..3ff67534 100644 --- a/src/shared/entities/billilge/billilgeQueries.ts +++ b/src/shared/entities/billilge/billilgeQueries.ts @@ -9,11 +9,14 @@ import { z } from "zod"; import { api, type CursorSliceResponse, isApiError } from "@/shared/api"; import { billilgeKeys } from "@/shared/entities/billilge/billilgeKeys"; import type { + BillilgeAdminRentalStatusUpdate, BillilgeCategory, BillilgeRentalApplyRequest, } from "@/shared/entities/billilge/billilgeTypes"; +import { notificationsKeys } from "@/shared/entities/notifications/notificationsKeys"; const BILLILGE_PATH = "/v1/app/billilge"; +const BILLILGE_ADMIN_PATH = "/v1/admin/billilge"; // 서버 enum(ItemCategory·ItemType·RentalStatus) 값 — 타입은 billilgeTypes.ts가 여기서 만든다 export const BILLILGE_CATEGORIES = [ @@ -155,6 +158,8 @@ export const BILLILGE_ERROR_CODES = { itemNotFound: "ITEM_NOT_FOUND", // 반납 신청할 수 있는 대여(대여 중인 내 대여)가 아니다 — 이미 반납을 신청했거나 상태가 바뀌었다 rentalNotFound: "RENTAL_NOT_FOUND", + // 관리자 상태 변경 — 화면에서 본 상태와 지금 상태가 다르다(다른 관리자가 먼저 처리했거나 옛 화면에서 눌렀다) + rentalStatusChanged: "RENTAL_STATUS_CHANGED", } as const; // 대여 신청. 학생 신청은 승인 대기(PENDING)로 시작하고 재고는 신청할 때 바로 줄어든다. @@ -205,3 +210,35 @@ export function useReturnBillilgeRentalMutation() { }, }); } + +// 관리자 대여 상태 변경(PATCH /v1/admin/billilge/histories/{id}). 관리자 알림 화면은 화면에서 본 상태(expectedStatus)를 +// 같이 보내서, 그사이 다른 관리자가 처리했으면 서버가 RENTAL_STATUS_CHANGED(409)로 거절하고 아무것도 바꾸지 않는다. +// 성공해도, 그렇게 거절돼도 관리자 알림 목록의 대여 상태가 낡아서 다시 받는다 — Promise를 돌려줘서 다시 받은 상태가 +// 들어온 뒤에 mutation이 끝나게 한다(버튼 로딩이 그때까지 이어진다). +export function useUpdateAdminRentalStatusMutation() { + const queryClient = useQueryClient(); + const refetchAdminNotifications = () => + queryClient.invalidateQueries({ + queryKey: notificationsKeys.adminList(), + }); + + return useMutation({ + mutationFn: ({ + rentalHistoryId, + ...request + }: BillilgeAdminRentalStatusUpdate) => + api.patch( + `${BILLILGE_ADMIN_PATH}/histories/${rentalHistoryId}`, + request, + ), + onSuccess: refetchAdminNotifications, + onError: (error) => { + if ( + isApiError(error) && + error.code === BILLILGE_ERROR_CODES.rentalStatusChanged + ) { + return refetchAdminNotifications(); + } + }, + }); +} diff --git a/src/shared/entities/billilge/billilgeTypes.ts b/src/shared/entities/billilge/billilgeTypes.ts index 6af8c529..ba71b0ab 100644 --- a/src/shared/entities/billilge/billilgeTypes.ts +++ b/src/shared/entities/billilge/billilgeTypes.ts @@ -53,3 +53,11 @@ export interface BillilgeRentalApplyRequest { // false면 같은 물품에 끝나지 않은 신청이 있을 때 RENTAL_ITEM_DUPLICATED로 거절된다 — true로 다시 보내면 그래도 신청한다 ignoreDuplicate: boolean; } + +// 관리자 대여 상태 변경(PATCH /v1/admin/billilge/histories/{id}) — 관리자 알림 화면의 반려·승인·완료 버튼이 쓴다 +export interface BillilgeAdminRentalStatusUpdate { + rentalHistoryId: number; + rentalStatus: BillilgeRentalStatus; + // 화면에서 본 지금 상태 — 실제 지금 상태와 다르면 서버가 RENTAL_STATUS_CHANGED로 거절하고 아무것도 바꾸지 않는다 + expectedStatus: BillilgeRentalStatus; +} diff --git a/src/shared/entities/notifications/notificationsKeys.ts b/src/shared/entities/notifications/notificationsKeys.ts index 527c1029..88345a60 100644 --- a/src/shared/entities/notifications/notificationsKeys.ts +++ b/src/shared/entities/notifications/notificationsKeys.ts @@ -1,4 +1,5 @@ export const notificationsKeys = { root: () => ["notifications"] as const, list: () => [...notificationsKeys.root(), "list"] as const, + adminList: () => [...notificationsKeys.root(), "admin", "list"] as const, }; diff --git a/src/shared/entities/notifications/notificationsMock.ts b/src/shared/entities/notifications/notificationsMock.ts deleted file mode 100644 index 6878439a..00000000 --- a/src/shared/entities/notifications/notificationsMock.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { NotificationItem } from "@/shared/entities/notifications/notificationsTypes"; - -// Figma: 알림 (nodeId 3595:107100) 목업 데이터 — 첫 항목만 안 읽은 알림이다. -export const NOTIFICATIONS: NotificationItem[] = [ - { - id: "1", - isRead: false, - message: "우산 반납이 완료됐어요.", - timeLabel: "1시간 전", - type: "billilgeReturn", - }, - { - id: "2", - isRead: true, - message: - "관리자가 대여 신청을 승인했어요. 학생회실에 방문해 우산을 대여해 주세요.", - timeLabel: "1시간 전", - type: "billilgeRental", - }, - { - id: "3", - isRead: true, - message: "새로운 공지사항이 올라왔어요. 자세한 내용을 확인해 주세요.", - timeLabel: "1시간 전", - type: "notices", - }, - { - id: "4", - isRead: true, - message: - "열린피드백에 새로운 답변이 올라왔어요. 어떤 답변인지 확인해 보세요.", - timeLabel: "1시간 전", - type: "feedbacks", - }, - { - id: "5", - isRead: true, - message: - "‘2026-02 기말고사 간식행사’가 내일 열려요. 행사 일정과 장소를 확인해 주세요.", - timeLabel: "1시간 전", - type: "events", - }, - { - id: "6", - isRead: true, - message: "사물함을 신청했어요. 배정된 사물함 번호를 확인해 주세요.", - timeLabel: "1시간 전", - type: "lockers", - }, -]; diff --git a/src/shared/entities/notifications/notificationsQueries.ts b/src/shared/entities/notifications/notificationsQueries.ts index 3382eff9..1ee8d01a 100644 --- a/src/shared/entities/notifications/notificationsQueries.ts +++ b/src/shared/entities/notifications/notificationsQueries.ts @@ -1,14 +1,164 @@ -import { queryOptions } from "@tanstack/react-query"; +import { + type InfiniteData, + infiniteQueryOptions, + type QueryClient, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { z } from "zod"; -import { mockupResponse } from "@/lib/mockupApi"; +import { api, type CursorSliceResponse } from "@/shared/api"; +import { BILLILGE_RENTAL_STATUSES } from "@/shared/entities/billilge/billilgeQueries"; import { notificationsKeys } from "@/shared/entities/notifications/notificationsKeys"; -import { NOTIFICATIONS } from "@/shared/entities/notifications/notificationsMock"; -// 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. +const NOTIFICATIONS_PATH = "/v1/app/notifications"; +const ADMIN_NOTIFICATIONS_PATH = "/v1/admin/notifications"; + +// 관리자 알림함은 처리가 끝난 알림을 화면에서 거르느라 한 페이지에 보일 게 적을 수 있어서 서버 상한(100)씩 받는다 +const ADMIN_NOTIFICATIONS_PAGE_SIZE = 100; + +// 알림 한 건(서버 NotificationListItemResponse). type은 서버 NotificationType 이름이다 — 새 종류가 생겨도 +// 목록 전체가 깨지지 않게 문자열로 받고, 화면이 아는 종류만 그린다. +export const notificationListItemSchema = z.looseObject({ + notificationId: z.number(), + type: z.string(), + title: z.string(), + // 한 문장짜리 알림은 본문이 없다 + body: z.string().nullable(), + targetId: z.number().nullable(), + read: z.boolean(), + // KST 일시("2026-11-05T13:59:00.123456") + createdAt: z.string(), +}); + +const notificationPageSchema = z.looseObject({ + content: z.array(notificationListItemSchema), + hasNext: z.boolean(), + nextCursor: z.string().nullable(), +}); + +// 관리자 알림이 가리키는 대여 기록의 지금 값(서버 RentalSummary). 알림이 만들어진 시점 값이 아니다. +export const adminRentalSummarySchema = z.looseObject({ + rentalHistoryId: z.number(), + // 물품이 삭제됐으면 null + itemName: z.string().nullable(), + // 물품이 삭제됐거나 이미지가 없으면 null + itemImageUrl: z.string().nullable(), + rentedCount: z.number(), + // 탈퇴한 회원이면 서버가 "(탈퇴한 회원)"을 넣는다 + memberName: z.string(), + // PENDING·CONFIRMED는 학생이 고른 희망 시각, RENTAL부터는 실제 수령 시각 + rentAt: z.string().nullable(), + rentalStatus: z.enum(BILLILGE_RENTAL_STATUSES), +}); + +// 관리자 공용 알림 한 건(서버 AdminNotificationListItemResponse). title·body는 푸시 문구로 만들 때 고정되므로 +// 화면의 대여 건 표시와 버튼은 rental로 고른다. 대여 알림이 아니거나 대여 기록이 지워졌으면 rental은 null이다. +export const adminNotificationListItemSchema = + notificationListItemSchema.extend({ + rental: adminRentalSummarySchema.nullable(), + }); + +const adminNotificationPageSchema = z.looseObject({ + content: z.array(adminNotificationListItemSchema), + hasNext: z.boolean(), + nextCursor: z.string().nullable(), +}); + +type NotificationPage = z.infer; + +// 커서 목록의 다음 페이지 — 마지막 페이지면 null이라 더 받지 않는다 +function getNextCursor(lastPage: CursorSliceResponse) { + return lastPage.hasNext ? lastPage.nextCursor : null; +} + +// 읽음 처리한 알림을 받아 둔 페이지에서 바로 읽음으로 바꾼다 — 목록을 다시 받지 않아도 배경이 바로 바뀐다 +function markReadInCache( + queryClient: QueryClient, + queryKey: readonly unknown[], + notificationId: number, +) { + queryClient.setQueryData>( + queryKey, + (data) => + data && { + ...data, + pages: data.pages.map((page) => ({ + ...page, + content: page.content.map((notification) => + notification.notificationId === notificationId + ? { ...notification, read: true } + : notification, + ), + })), + }, + ); +} + +// 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. export const notificationsQueries = { + // 내 알림함을 최신순으로 이어 받는다(GET /v1/app/notifications) list: () => - queryOptions({ + infiniteQueryOptions({ queryKey: notificationsKeys.list(), - queryFn: () => mockupResponse(() => NOTIFICATIONS), + queryFn: async ({ pageParam, signal }) => + notificationPageSchema.parse( + await api.get(NOTIFICATIONS_PATH, { + // axios는 null인 파라미터를 보내지 않는다 — 첫 페이지는 cursor 없이 간다 + params: { cursor: pageParam }, + signal, + }), + ), + initialPageParam: null as string | null, + getNextPageParam: getNextCursor, + }), + // 관리자 공용 알림함을 최신순으로 이어 받는다(GET /v1/admin/notifications) — 관리자 전체가 같은 목록과 읽음 상태를 본다 + adminList: () => + infiniteQueryOptions({ + queryKey: notificationsKeys.adminList(), + queryFn: async ({ pageParam, signal }) => + adminNotificationPageSchema.parse( + await api.get(ADMIN_NOTIFICATIONS_PATH, { + params: { cursor: pageParam, size: ADMIN_NOTIFICATIONS_PAGE_SIZE }, + signal, + }), + ), + initialPageParam: null as string | null, + getNextPageParam: getNextCursor, }), }; + +// 내 알림 읽음 처리(PATCH /v1/app/notifications/{id}/read). 이미 읽은 알림이어도 서버는 성공으로 응답한다. +// 누르자마자 배경이 바뀌도록 받아 둔 목록을 먼저 고치고, 실패하면 목록을 다시 받아 서버 값으로 돌린다. +export function useMarkNotificationReadMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (notificationId: number) => + api.patch(`${NOTIFICATIONS_PATH}/${notificationId}/read`), + onMutate: (notificationId) => + markReadInCache(queryClient, notificationsKeys.list(), notificationId), + onError: () => + queryClient.invalidateQueries({ queryKey: notificationsKeys.list() }), + }); +} + +// 관리자 공용 알림 읽음 처리(PATCH /v1/admin/notifications/{id}/read) — 누가 처리해도 관리자 전체가 읽음으로 본다 +export function useMarkAdminNotificationReadMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (notificationId: number) => + api.patch(`${ADMIN_NOTIFICATIONS_PATH}/${notificationId}/read`), + onMutate: (notificationId) => + markReadInCache( + queryClient, + notificationsKeys.adminList(), + notificationId, + ), + onError: () => + queryClient.invalidateQueries({ + queryKey: notificationsKeys.adminList(), + }), + }); +} diff --git a/src/shared/entities/notifications/notificationsTypes.ts b/src/shared/entities/notifications/notificationsTypes.ts index 9b655d03..45a0080d 100644 --- a/src/shared/entities/notifications/notificationsTypes.ts +++ b/src/shared/entities/notifications/notificationsTypes.ts @@ -1,4 +1,13 @@ +import type { z } from "zod"; + +import type { + adminNotificationListItemSchema, + adminRentalSummarySchema, + notificationListItemSchema, +} from "@/shared/entities/notifications/notificationsQueries"; + // 알림 종류 — 아이콘·라벨이 종류마다 다르다. 빌릴게는 대여/반납 아이콘이 따로라 둘로 나눴다. +// 서버 NotificationType 이름을 이 종류로 바꾸는 표는 features/notifications/constants/notifications.ts에 있다. export type NotificationType = | "billilgeRental" | "billilgeReturn" @@ -7,11 +16,13 @@ export type NotificationType = | "events" | "lockers"; -export interface NotificationItem { - id: string; - type: NotificationType; - message: string; - /** 이미 포맷된 표시용 문자열 (예: "1시간 전") — 실 API가 시각을 주면 화면 쪽에서 상대 시간으로 바꾼다 */ - timeLabel: string; - isRead: boolean; -} +// 내 알림 한 건(GET /v1/app/notifications) +export type NotificationListItem = z.infer; + +// 관리자 공용 알림 한 건(GET /v1/admin/notifications) +export type AdminNotificationListItem = z.infer< + typeof adminNotificationListItemSchema +>; + +// 관리자 알림이 가리키는 대여 기록의 지금 값 +export type AdminRentalSummary = z.infer;