Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,23 @@ const routes = [
path: "/search",
},
{
element: <NotificationsScreen />,
element: <NotificationsScreen tab="mine" />,
// Figma 알림 화면에는 Bottom Nav가 없고(뒤로가기로 진입한 화면에 복귀) 배경이 흰 면이다
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/notifications",
},
{
element: <NotificationsScreen tab="admin" />,
// 알림 화면의 관리자 알림 탭 — 같은 화면을 탭만 바꿔 그리고, 알림 화면과 같은 이유로 Bottom Nav 없이 흰 면이다
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/notifications/admin",
},
{
element: <NotificationsSettingsScreen />,
// 알림 화면의 설정 아이콘으로 들어가는 화면 — 알림 화면과 같은 이유로 Bottom Nav 없이 흰 면이다
Expand Down
5 changes: 5 additions & 0 deletions src/assets/icons/notifications/billilge-rental-request.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/icons/notifications/billilge-return-request.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 57 additions & 7 deletions src/features/notifications/NotificationsScreen.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ScreenHeader
Expand Down Expand Up @@ -45,10 +65,40 @@ function NotificationsScreen() {
/>,
);

// 관리자가 아니면 토글을 그리지 않고, 관리자 탭 경로로 들어와도 내 알림으로 돌린다
if (tab === "admin" && !isAdmin) {
return <Navigate replace to={NOTIFICATIONS_PATH} />;
}

return (
<Suspense fallback={<NotificationsListSkeleton />}>
<NotificationsList />
</Suspense>
<>
{isAdmin && (
// 목록만 스크롤되도록 고정(shrink-0)한다. 바로 아래 안 읽은 알림의 파란 배경이 토글에 붙지 않게 8px 띄운다
<div className="shrink-0 px-5 pb-2">
<SegmentedControl
// 탭을 바꿀 때마다 히스토리가 쌓이지 않게 replace로 바꾼다
onValueChange={(value) =>
navigate(NOTIFICATIONS_TAB_PATHS[value as NotificationsTab], {
replace: true,
})
}
size="small"
value={tab}
>
<SegmentedControlItem value="mine">내 알림</SegmentedControlItem>
<SegmentedControlItem value="admin">
관리자 알림
</SegmentedControlItem>
</SegmentedControl>
</div>
)}

{/* 탭 전환은 라우터 이동(transition)이라 같은 경계면 새 탭 데이터를 받는 동안 이전 탭을 붙잡는다 —
key로 탭마다 다른 경계로 둔다(행사 목록과 같은 이유). 읽음 state도 탭마다 따로 시작한다. */}
<Suspense fallback={<NotificationsListSkeleton />} key={tab}>
{tab === "admin" ? <NotificationsAdminList /> : <NotificationsList />}
</Suspense>
</>
);
}

Expand Down
156 changes: 156 additions & 0 deletions src/features/notifications/components/NotificationsAdminItem.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img alt="" className="size-6 shrink-0" src={icon} />
<Typography
color="semantic.label.alternative"
variant="caption1"
weight="regular"
>
{label}
</Typography>
</div>
<Typography
color="semantic.label.alternative"
variant="caption2"
weight="regular"
>
{timeLabel}
</Typography>
</div>
);

// 버튼 때문에 문구 폭이 좁아서 "신청\n했어요"처럼 단어 중간에서 줄이 바뀌지 않게 단어 단위로 줄을 바꾼다
const message = (
<div className="flex min-w-0 flex-1 flex-col gap-0.5 break-keep">
<Typography
color="semantic.label.normal"
id={titleId}
variant="label1-reading"
weight="medium"
>
{!isRead && <span className="sr-only">안 읽은 알림. </span>}
{title}
</Typography>
{body !== null && (
<Typography
color="semantic.label.alternative"
variant="label2"
weight="regular"
>
{body}
</Typography>
)}
</div>
);

if (action === null) {
return (
<button className={className} onClick={onRead} type="button">
{header}
{/* 아이콘(24) + 간격(8) 만큼 들여 라벨과 같은 줄에서 시작하고, 오른쪽은 38px(pr-9.5) 비운다 */}
<div className="flex pr-9.5 pl-8">{message}</div>
</button>
);
}

const { step, completeLabel, pendingAction, onAction } = action;
const isPending = pendingAction !== null;

return (
<div className={className}>
{header}
<div className="flex items-center gap-3 pl-8">
{message}
<div className="flex shrink-0 gap-1.5">
{step === "handover" ? (
<Button
aria-describedby={titleId}
color="primary"
disabled={isPending && pendingAction !== "complete"}
loading={pendingAction === "complete"}
onClick={() => onAction("complete")}
size="small"
variant="solid"
>
{completeLabel}
</Button>
) : (
<>
<Button
aria-describedby={titleId}
color="assistive"
disabled={isPending && pendingAction !== "reject"}
loading={pendingAction === "reject"}
onClick={() => onAction("reject")}
size="small"
variant="outlined"
>
반려
</Button>
<Button
aria-describedby={titleId}
color="primary"
disabled={isPending && pendingAction !== "approve"}
loading={pendingAction === "approve"}
onClick={() => onAction("approve")}
size="small"
variant="solid"
>
승인
</Button>
</>
)}
</div>
</div>
</div>
);
}

export default NotificationsAdminItem;
Loading
Loading