import { useEffect, useRef, type ReactNode } from 'react'; import { useNavigate } from 'react-router'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react'; import { PageContainer } from '@/components/layout/PageContainer'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; import { listNotifications, useReadAll, useReadOne, } from '@/api/generated/notification/notification'; import { NotificationType } from '@/api/generated/model/notificationType'; import type { NotificationData } from '@/api/generated/model/notificationData'; const PAGE_SIZE = 20; export default function NotificationsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); // offset 페이징(page/size)을 그대로 쓰는 무한 스크롤: page 를 1→2→3 누적. // 다음 페이지 여부는 응답의 total/page/size 로 판정(page*size < total). const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['/v1/notification/list', 'infinite'], queryFn: ({ pageParam }) => listNotifications({ page: pageParam, size: PAGE_SIZE }), initialPageParam: 1, getNextPageParam: (last) => { const page = last.page ?? 1; const size = last.size ?? PAGE_SIZE; const total = last.total ?? 0; return page * size < total ? page + 1 : undefined; }, }); const readAll = useReadAll(); const readOne = useReadOne(); const items = data?.pages.flatMap((p) => p.notifications ?? []) ?? []; const unread = data?.pages[0]?.unread ?? 0; const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] }); const openOne = (n: NotificationData) => { if (!n.read_at) readOne.mutate({ notificationId: n.notification_id }, { onSuccess: invalidate }); if (n.ref_qt_id) navigate(`/quotation?detail=${n.ref_qt_id}`); }; // 리스트 끝 sentinel 이 뷰포트에 들어오면 다음 페이지 로드(무한 스크롤). const sentinelRef = useRef(null); useEffect(() => { const el = sentinelRef.current; if (!el) return; const io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { rootMargin: '120px' }, ); io.observe(el); return () => io.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); return (
알림 {unread > 0 && ( {unread} 안읽음 )}
{items.length === 0 ? (
알림이 없습니다.
) : ( items.map((n) => { const r = render(n); const isUnread = !n.read_at; return ( ); }) )}
{hasNextPage && (
{isFetchingNextPage && ( 불러오는 중… )}
)}
); } // 서버 시각(타임존 표식 없는 UTC) → 한국시간 'YYYY-MM-DD HH:mm'. 실패 시 '-'. function fmtKst(s?: string | null): string { if (!s) return '-'; const iso = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(s) ? s : s + 'Z'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return '-'; const p = new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(d).reduce((o, x) => ((o[x.type] = x.value), o), {} as Record); return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; } // 견적건명(name, 필수 입력). 비면 번호, 둘 다 없으면 '견적'. function qtName(d: Record): string { const name = (d.qt_name as string) || ''; const num = (d.qt_number as string) || ''; return name || num || '견적'; } // 알림 1건 → 카드 3단 표기값. // event : 이벤트 제목(낙찰/재생성/결렬/생성) — 한 줄에 쭉 늘어놓지 않고 분리 // line : 무슨 견적인지(건명) + 결과 // number: 견적번호(메타줄 보조 표기). icon/tone 은 유형별 색. function render(n: NotificationData): { icon: ReactNode; tone: string; event: string; line: string; number: string } { const d = (n.data ?? {}) as Record; const name = qtName(d); const number = (d.qt_number as string) || ''; switch (n.type) { case NotificationType.CREATED: return { icon: , tone: 'text-sky-600', event: '견적 생성', line: name, number }; case NotificationType.SUCCESS: return { icon: , tone: 'text-emerald-600', event: '견적 낙찰', line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`, number, }; case NotificationType.REGENERATED: return { icon: , tone: 'text-amber-600', event: `견적 재생성 · ${d.reason === 'equal' ? '동가' : '전원 미참여'}`, line: `${name} — ${d.next_round ?? ''}차로 다시 생성`, number, }; case NotificationType.FAILURE: // 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기. return { icon: , tone: 'text-amber-600', event: '견적 개찰', line: `${name} — 낙찰자 미정 (${ ({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record)[ String(d.reason) ] ?? '마감' })`, number, }; default: return { icon: , tone: 'text-muted-foreground', event: '견적 알림', line: name, number }; } }