o2o-negosium-original/negodata/front/src/pages/notifications.tsx

194 lines
8.6 KiB
TypeScript

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<HTMLDivElement>(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 (
<PageContainer>
<div className="flex items-center justify-between p-4 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2">
<Bell size={18} className="text-foreground" />
<Typography as="span" variant="small" className="font-bold">알림</Typography>
{unread > 0 && (
<Typography as="span" variant="small" className="text-xs font-bold text-destructive">{unread} 안읽음</Typography>
)}
</div>
<button
type="button"
onClick={() => readAll.mutate(undefined, { onSuccess: invalidate })}
disabled={unread === 0}
className="flex items-center gap-1.5 px-3 py-2 bg-muted text-foreground border border-border text-xs font-semibold rounded hover:bg-muted-foreground/10 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
<CheckCheck size={14} />
<Typography as="span" variant="small" className="text-xs text-inherit">모두 읽음</Typography>
</button>
</div>
<div className="rounded-lg border border-border bg-card divide-y divide-border overflow-hidden">
{items.length === 0 ? (
<div className="p-10 text-center">
<Typography as="p" variant="small" className="text-muted-foreground">알림이 없습니다.</Typography>
</div>
) : (
items.map((n) => {
const r = render(n);
const isUnread = !n.read_at;
return (
<button
key={n.notification_id}
type="button"
onClick={() => openOne(n)}
className={cn(
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-muted/60 transition-colors cursor-pointer',
isUnread && 'bg-primary/5'
)}
>
<span className={cn('shrink-0 mt-0.5', r.tone)}>{r.icon}</span>
<div className="flex-1 min-w-0">
{/* 1줄: 이벤트(낙찰/재생성/결렬/생성) — 제목 */}
<Typography as="div" variant="small" className={cn('text-xs font-bold', r.tone)}>
{r.event}
</Typography>
{/* 2줄: 무슨 견적인지(건명) + 결과 */}
<Typography as="div" variant="small" className={cn('text-sm truncate', isUnread ? 'font-bold text-foreground' : 'text-foreground/80')}>
{r.line}
</Typography>
{/* 3줄: 견적번호 · 날짜 — quotation 상세 헤더와 같은 mono 보조 표기 */}
<Typography as="div" variant="small" className="text-[11px] font-mono text-muted-foreground mt-0.5">
{r.number ? `${r.number} · ` : ''}{fmtKst(n.created_at)}
</Typography>
</div>
{isUnread && <span className="shrink-0 mt-1.5 h-2 w-2 rounded-full bg-destructive" />}
</button>
);
})
)}
</div>
{hasNextPage && (
<div ref={sentinelRef} className="py-3 text-center">
{isFetchingNextPage && (
<Typography as="span" variant="small" className="text-muted-foreground">불러오는 중…</Typography>
)}
</div>
)}
</PageContainer>
);
}
// 서버 시각(타임존 표식 없는 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<string, string>);
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`;
}
// 견적건명(name, 필수 입력). 비면 번호, 둘 다 없으면 '견적'.
function qtName(d: Record<string, unknown>): 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<string, unknown>;
const name = qtName(d);
const number = (d.qt_number as string) || '';
switch (n.type) {
case NotificationType.CREATED:
return { icon: <FilePlus2 size={18} />, tone: 'text-sky-600', event: '견적 생성', line: name, number };
case NotificationType.SUCCESS:
return {
icon: <Trophy size={18} />, tone: 'text-emerald-600', event: '견적 낙찰',
line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`,
number,
};
case NotificationType.REGENERATED:
return {
icon: <RefreshCw size={18} />, tone: 'text-amber-600',
event: `견적 재생성 · ${d.reason === 'equal' ? '동가' : '전원 미참여'}`,
line: `${name} — ${d.next_round ?? ''}차로 다시 생성`,
number,
};
case NotificationType.FAILURE:
// 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기.
return {
icon: <XCircle size={18} />, tone: 'text-amber-600', event: '견적 개찰',
line: `${name} — 낙찰자 미정 (${
({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record<string, string>)[
String(d.reason)
] ?? '마감'
})`,
number,
};
default:
return { icon: <Bell size={18} />, tone: 'text-muted-foreground', event: '견적 알림', line: name, number };
}
}