All checks were successful
SPMS_BO/pipeline/head This commit looks good
- 알림 상세 슬라이드 패널 (NotificationSlidePanel) 신규 생성 - 헤더 알림 드롭다운에서 클릭 시 알림 페이지 이동 + 패널 자동 오픈 - 프로필 수정 필수값 검증: useShake + 인라인 에러 메시지 패턴 적용 - 사이드바 프로필 아이콘 클릭 시 마이페이지 이동 - 사이드바 메뉴 그룹 경로 변경 시 자동 접힘 처리 - 대시보드, 마이페이지 UI 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
9.1 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|