[CLAUDE] App+Domain+Infra+Api+FE: Notifications module end-to-end
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
Domain: - Notification entity + NotificationType enum (stable ints) - Nullable RefId cho correlation (contract, user, ...) Infrastructure: - NotificationConfiguration: bảng Notifications, index theo (UserId, ReadAt) - NotificationService: ghi vào DbContext, không SaveChanges (để caller quyết định unit-of-work — đảm bảo atomic với domain mutation) - EF migration AddNotifications Application: - INotificationService (Notify + NotifyMany) - CQRS: ListMyNotifications / GetMyUnreadCount / MarkRead / MarkAllRead Api: - NotificationsController: GET /api/notifications + unread-count + mark-read Integration: - ContractWorkflowService emit notification tới Drafter khi HĐ chuyển phase (skip nếu actor chính là Drafter). Title + type theo phase đích: DaPhatHanh → ContractPublished, TuChoi → ContractRejected, khác → ContractPhaseTransition. FE: - Both NotificationBell (admin + user) dùng /api/notifications thật (thay cho derived-from-inbox MVP trước đó). 30s refetch, click mark-read, 'Đọc hết' bulk action. Foundation sẵn cho SignalR push + email outbox sau này — chỉ cần mở rộng NotificationService mà không đổi caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,57 +1,56 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { Bell, CheckCheck } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { ContractListItem } from '@/types/contracts'
|
||||
import type { Paged } from '@/types/master'
|
||||
|
||||
type Notification = {
|
||||
type NotificationDto = {
|
||||
id: string
|
||||
type: number
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
description: string | null
|
||||
href: string | null
|
||||
refId: string | null
|
||||
createdAt: string
|
||||
read: boolean
|
||||
readAt: string | null
|
||||
}
|
||||
|
||||
// MVP: surface overdue-SLA contracts as notifications. Future iteration will
|
||||
// replace with a dedicated notifications endpoint fed by domain events + SignalR.
|
||||
function useOverdueNotifications() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications-overdue'],
|
||||
queryFn: async (): Promise<Notification[]> => {
|
||||
const { data } = await api.get<Paged<ContractListItem>>('/contracts', {
|
||||
params: { page: 1, pageSize: 50 },
|
||||
})
|
||||
const now = Date.now()
|
||||
return data.items
|
||||
.filter(c => c.slaDeadline && new Date(c.slaDeadline).getTime() < now + 24 * 60 * 60 * 1000)
|
||||
.map(c => {
|
||||
const deadline = c.slaDeadline ? new Date(c.slaDeadline).getTime() : null
|
||||
const overdue = deadline !== null && deadline < now
|
||||
return {
|
||||
id: c.id,
|
||||
title: `${overdue ? 'Quá hạn SLA' : 'Sắp quá hạn'} — ${c.maHopDong ?? c.tenHopDong ?? 'HĐ chưa có mã'}`,
|
||||
description: `Phase ${c.phase} · NCC ${c.supplierName}`,
|
||||
href: `/contracts/${c.id}`,
|
||||
createdAt: c.createdAt,
|
||||
read: false,
|
||||
}
|
||||
})
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const fmtWhen = (iso: string) => {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60_000)
|
||||
if (mins < 1) return 'vừa xong'
|
||||
if (mins < 60) return `${mins}p trước`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h trước`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d trước`
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const q = useOverdueNotifications()
|
||||
const items = q.data ?? []
|
||||
const unread = items.filter(n => !n.read).length
|
||||
const qc = useQueryClient()
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: async () => (await api.get<NotificationDto[]>('/notifications', { params: { limit: 20 } })).data,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const items = list.data ?? []
|
||||
const unread = items.filter(n => !n.readAt).length
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: async (id: string) => api.post(`/notifications/${id}/read`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
})
|
||||
|
||||
const markAllRead = useMutation({
|
||||
mutationFn: async () => api.post('/notifications/read-all'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@ -79,42 +78,50 @@ export function NotificationBell() {
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||
<div className="border-b border-slate-100 px-4 py-2.5">
|
||||
<div className="text-sm font-semibold text-slate-700">Cảnh báo SLA</div>
|
||||
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} HĐ cần chú ý` : 'Không có cảnh báo mới'}</div>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-700">Thông báo</div>
|
||||
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} chưa đọc` : 'Đã đọc hết'}</div>
|
||||
</div>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={() => markAllRead.mutate()}
|
||||
disabled={markAllRead.isPending}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-brand-600 transition hover:bg-brand-50"
|
||||
title="Đánh dấu tất cả đã đọc"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Đọc hết
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{items.length === 0 && (
|
||||
<div className="px-4 py-10 text-center text-xs text-slate-400">Mọi HĐ đang đúng tiến độ.</div>
|
||||
<div className="px-4 py-10 text-center text-xs text-slate-400">Không có thông báo nào.</div>
|
||||
)}
|
||||
{items.map(n => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
navigate(n.href)
|
||||
if (!n.readAt) markRead.mutate(n.id)
|
||||
if (n.href) navigate(n.href)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'block w-full border-b border-slate-50 px-4 py-3 text-left transition hover:bg-slate-50',
|
||||
!n.read && 'bg-amber-50/40',
|
||||
!n.readAt && 'bg-brand-50/30',
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
||||
<div className="truncate text-xs text-slate-400">{n.description}</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
||||
{n.description && <div className="truncate text-xs text-slate-400">{n.description}</div>}
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-slate-400">{fmtWhen(n.createdAt)}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/contracts')
|
||||
setOpen(false)
|
||||
}}
|
||||
className="block w-full border-t border-slate-100 px-4 py-2 text-center text-xs font-medium text-brand-600 hover:bg-slate-50"
|
||||
>
|
||||
Xem toàn bộ HĐ →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user