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;