SPMS_WEB/react/src/features/settings/pages/NotificationsPage.tsx
SEAN ca88d5ba08
All checks were successful
SPMS_BO/pipeline/head This commit looks good
feat: 설정 페이지 기능 개선 및 알림 상세 패널 구현
- 알림 상세 슬라이드 패널 (NotificationSlidePanel) 신규 생성
- 헤더 알림 드롭다운에서 클릭 시 알림 페이지 이동 + 패널 자동 오픈
- 프로필 수정 필수값 검증: useShake + 인라인 에러 메시지 패턴 적용
- 사이드바 프로필 아이콘 클릭 시 마이페이지 이동
- 사이드바 메뉴 그룹 경로 변경 시 자동 접힘 처리
- 대시보드, 마이페이지 UI 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:30:22 +09:00

249 lines
9.1 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import { MOCK_NOTIFICATIONS, type Notification, type NotificationType } from "../types";
import NotificationSlidePanel from "../components/NotificationSlidePanel";
// 타입별 배지 스타일
const BADGE_STYLE: Record<
NotificationType,
{ bg: string; text: string; icon: string }
> = {
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
};
// 날짜 그룹 레이블 → 표시 날짜
const DATE_LABELS: Record<string, string> = {
"오늘": "2026-02-19",
"어제": "2026-02-18",
"이번 주": "2026.02.16 ~ 02.17",
};
const PAGE_SIZE = 5;
export default function NotificationsPage() {
const location = useLocation();
const navigate = useNavigate();
const [notifications, setNotifications] = useState<Notification[]>(
() => [...MOCK_NOTIFICATIONS],
);
const [currentPage, setCurrentPage] = useState(1);
const [selectedNotification, setSelectedNotification] =
useState<Notification | null>(null);
// 헤더 드롭다운에서 넘어온 경우 해당 알림 패널 자동 오픈
useEffect(() => {
const state = location.state as { notificationId?: number } | null;
if (state?.notificationId) {
const target = notifications.find((n) => n.id === state.notificationId);
if (target) {
setSelectedNotification(target);
// 읽음 처리
if (!target.read) {
setNotifications((prev) =>
prev.map((n) =>
n.id === target.id ? { ...n, read: true } : n,
),
);
setSelectedNotification({ ...target, read: true });
}
}
// state 소비 후 제거 (새로고침 시 재트리거 방지)
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
// 페이지네이션
const totalItems = notifications.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const pagedNotifications = useMemo(
() =>
notifications.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
),
[notifications, currentPage],
);
// 페이징된 알림을 날짜별로 그룹핑
const grouped = useMemo(() => {
const map = new Map<string, Notification[]>();
for (const n of pagedNotifications) {
const arr = map.get(n.date) ?? [];
arr.push(n);
map.set(n.date, arr);
}
return map;
}, [pagedNotifications]);
// 전체 읽음
const handleReadAll = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
toast.success("모든 알림을 읽음 처리했습니다");
}, []);
// 개별 읽음
const handleReadOne = useCallback((id: number) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
setSelectedNotification((prev) =>
prev?.id === id ? { ...prev, read: true } : prev,
);
}, []);
return (
<>
<PageHeader
title="알림"
description="시스템 알림과 발송 결과를 확인할 수 있습니다"
action={
<button
onClick={handleReadAll}
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-base">
done_all
</span>
</button>
}
/>
{/* 날짜별 그룹 */}
{Array.from(grouped.entries()).map(([dateLabel, items]) => (
<div key={dateLabel} className="mb-8">
{/* 그룹 헤더 */}
<div className="flex items-center gap-3 mb-3">
<h2 className="text-xs font-bold text-[#64748b] uppercase tracking-wider">
{dateLabel}
</h2>
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400">
{DATE_LABELS[dateLabel] ?? ""}
</span>
</div>
{/* 알림 카드 목록 */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{items.map((notification, idx) => {
const badge = BADGE_STYLE[notification.type];
const isUnread = !notification.read;
return (
<div
key={notification.id}
onClick={() => {
setSelectedNotification(notification);
handleReadOne(notification.id);
}}
className={`flex items-start gap-4 px-5 py-4 cursor-pointer transition-colors ${
idx < items.length - 1
? "border-b border-gray-100"
: ""
} ${
isUnread
? "bg-blue-50/20 hover:bg-blue-50/30"
: "hover:bg-gray-50"
}`}
>
{/* 읽음 상태 점 */}
<div className="flex-shrink-0 mt-0.5">
<div
className={`size-2.5 rounded-full ${
isUnread ? "bg-[#2563EB]" : "bg-gray-200"
}`}
/>
</div>
{/* 콘텐츠 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded ${badge.bg} ${badge.text} text-[10px] font-semibold`}
>
<span
className="material-symbols-outlined"
style={{ fontSize: "11px" }}
>
{badge.icon}
</span>
{notification.type}
</span>
<span className="text-xs text-gray-400">
{notification.time}
</span>
</div>
<p
className={`text-sm truncate ${
isUnread
? "font-semibold text-[#0f172a]"
: "font-medium text-gray-600"
}`}
>
{notification.title}
</p>
<p
className={`text-xs truncate mt-0.5 ${
isUnread ? "text-gray-500" : "text-gray-400"
}`}
>
{notification.description}
</p>
</div>
</div>
);
})}
</div>
</div>
))}
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-1 mt-10">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-xl">
chevron_left
</span>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`size-9 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-[#2563EB] text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-xl">
chevron_right
</span>
</button>
</div>
{/* 알림 상세 패널 */}
<NotificationSlidePanel
isOpen={selectedNotification !== null}
onClose={() => setSelectedNotification(null)}
notification={selectedNotification}
onMarkRead={handleReadOne}
/>
</>
);
}