194 lines
8.6 KiB
TypeScript
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 };
|
|
}
|
|
}
|