feat(notifications): TODO 7 — draft decision toast + email + bell badge
This commit is contained in:
@@ -687,3 +687,35 @@ export type ChangelogDiff = {
|
||||
after: unknown | null
|
||||
summary: ChangelogDiffSummary
|
||||
}
|
||||
|
||||
// === Notifications (TODO 7: draft decision toast + email) ===
|
||||
|
||||
/**
|
||||
* Single notification row для current user. Backend serializes
|
||||
* {@code notification_log} entry joined с {@code notification_read_state}
|
||||
* (per-user unread tracking).
|
||||
*
|
||||
* <p>{@code payload} — JSON payload эвента (e.g. RecordDraftApproved).
|
||||
* {@code payloadPreview} — server-pre-rendered summary string чтобы UI
|
||||
* не парсил event payload (event_type → user-facing text mapping живёт
|
||||
* backend'е).
|
||||
*/
|
||||
export type NotificationItem = {
|
||||
id: string
|
||||
eventType: string
|
||||
channel: 'EMAIL' | 'EXPRESS' | 'TELEGRAM' | 'IN_APP'
|
||||
sentAt: string
|
||||
read: boolean
|
||||
payloadPreview: string
|
||||
/** Optional deep-link (e.g. /reviews/{draftId} для draft decision). */
|
||||
href?: string | null
|
||||
}
|
||||
|
||||
export type NotificationsResponse = {
|
||||
items: NotificationItem[]
|
||||
page: number
|
||||
size: number
|
||||
totalElements: number
|
||||
/** Unread count for badge — pre-filtered server-side. */
|
||||
totalUnread: number
|
||||
}
|
||||
|
||||
@@ -664,3 +664,39 @@ export const useRetryWebhookDelivery = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// === Notifications mutations (TODO 7) ===
|
||||
|
||||
/**
|
||||
* Mark одно notification как read. Optimistic update — UI обновляется
|
||||
* мгновенно, query invalidates после server confirm. Если backend reject'нет,
|
||||
* onError refetch'нет original state.
|
||||
*/
|
||||
export const useMarkNotificationRead = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (notificationId: string): Promise<void> => {
|
||||
await apiClient.post(
|
||||
`/me/notifications/${encodeURIComponent(notificationId)}/read`,
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark все unread notifications as read. Drawer footer button "Mark all read".
|
||||
*/
|
||||
export const useMarkAllNotificationsRead = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<void> => {
|
||||
await apiClient.post('/me/notifications/read-all')
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CascadePlan,
|
||||
type ChangelogDiff,
|
||||
type ChangelogResponse,
|
||||
type NotificationsResponse,
|
||||
type SchemaDraft,
|
||||
type SchemaReviewQueuePage,
|
||||
type DictionaryDefinition,
|
||||
@@ -804,3 +805,36 @@ export const serverVersionQuery = queryOptions({
|
||||
})
|
||||
|
||||
export const useServerVersion = () => useQuery(serverVersionQuery)
|
||||
|
||||
// === Notifications (TODO 7: draft decision toast + email) ===
|
||||
|
||||
/**
|
||||
* Current user's notifications feed. Backend filters by recipient = sub claim;
|
||||
* returns paginated rows + `totalUnread` для bell badge counter.
|
||||
*
|
||||
* <p>Poll каждые 30s — same cadence как /reviews queue. {@code staleTime} 5s
|
||||
* чтобы query cache не моргал когда user открывает/закрывает drawer.
|
||||
* {@code refetchIntervalInBackground} = true чтобы tab без focus всё равно
|
||||
* decremented badge когда mark-as-read fires.
|
||||
*
|
||||
* <p>Caveat: `unreadOnly=true` gives a tight badge query (1 unread → badge "1"),
|
||||
* full feed query (default) used drawer открыт. Splitting queries keeps badge
|
||||
* fetch cheap when drawer закрыт.
|
||||
*/
|
||||
export const myNotificationsQuery = (unreadOnly = false, limit = 20) =>
|
||||
queryOptions({
|
||||
queryKey: ['notifications', 'me', { unreadOnly, limit }] as const,
|
||||
queryFn: async (): Promise<NotificationsResponse> => {
|
||||
const { data } = await apiClient.get<NotificationsResponse>(
|
||||
'/me/notifications',
|
||||
{ params: { unreadOnly, limit } },
|
||||
)
|
||||
return data
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
refetchIntervalInBackground: true,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
|
||||
useQuery(myNotificationsQuery(unreadOnly, limit))
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
||||
import { useDictionaries, useDictionaryDetail, useMyNotifications } from '@/api/queries'
|
||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||
import { WhatsNewButton } from '@/components/version/WhatsNewButton'
|
||||
import { NotificationsDrawer } from '@/components/notifications/NotificationsDrawer'
|
||||
import { toast } from '@/ui'
|
||||
import { Bell, Menu } from 'lucide-react'
|
||||
|
||||
/**
|
||||
@@ -101,25 +104,72 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder notifications bell — будет показывать N unread сообщений
|
||||
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
|
||||
* Сейчас static bell без counter — wire'ится когда backend notifications
|
||||
* stream появится.
|
||||
* Notifications bell с unread counter + drawer. TODO 7 wiring:
|
||||
* <ul>
|
||||
* <li>{@link useMyNotifications} с {@code unreadOnly=true} polls /me/notifications
|
||||
* каждые 30s (cheap query — фильтр backend-side).</li>
|
||||
* <li>Counter badge показывается если {@code totalUnread > 0}. Capped на "9+"
|
||||
* чтобы layout не плыл при бурном reviewer flow.</li>
|
||||
* <li>Click → opens {@link NotificationsDrawer} с full feed (unreadOnly=false).</li>
|
||||
* <li>Delta-detection: если {@code totalUnread} вырос между poll'ами и drawer
|
||||
* закрыт — fires `toast.info` с preview top-most new item. Reviewer'у
|
||||
* не нужно держать tab focused чтобы заметить approve.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>«Что нового» (WhatsNewButton) — adjacent button показывает release
|
||||
* notes, отделён от operational notifications (разные mental models).
|
||||
*/
|
||||
function NotificationsBell() {
|
||||
const { t } = useTranslation()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
// Light query: только totalUnread + top item, used to drive badge + toast.
|
||||
const q = useMyNotifications(true, 5)
|
||||
const totalUnread = q.data?.totalUnread ?? 0
|
||||
|
||||
// Delta toast: detect новые notifications между polls. Hold prev count
|
||||
// в ref чтобы не trigger'ить toast на первый mount (initial load).
|
||||
const prevUnreadRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = prevUnreadRef.current
|
||||
if (prev != null && totalUnread > prev && !drawerOpen) {
|
||||
const top = q.data?.items[0]
|
||||
toast(
|
||||
top?.payloadPreview ??
|
||||
t('notifications.newDefault', { defaultValue: 'Новое уведомление' }),
|
||||
{
|
||||
description: t('notifications.toastDescription', {
|
||||
defaultValue: 'Откройте колокольчик чтобы увидеть подробности.',
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
prevUnreadRef.current = totalUnread
|
||||
}, [totalUnread, drawerOpen, q.data, t])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Bell size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="relative size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Bell size={14} strokeWidth={1.75} />
|
||||
{totalUnread > 0 && (
|
||||
<span
|
||||
aria-label={t('notifications.unreadCount', {
|
||||
count: totalUnread,
|
||||
defaultValue: `${totalUnread} непрочитанных`,
|
||||
})}
|
||||
className="absolute -top-1 -right-1 min-w-[1rem] h-[1rem] px-1 rounded-full bg-pink text-on-accent text-[0.625rem] leading-none font-medium tabular-nums inline-flex items-center justify-center"
|
||||
>
|
||||
{totalUnread > 9 ? '9+' : totalUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<NotificationsDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Drawer,
|
||||
EmptyState,
|
||||
LoadingBlock,
|
||||
QueryErrorState,
|
||||
} from '@/ui'
|
||||
import { BellIcon, CheckIcon } from '@phosphor-icons/react'
|
||||
import { useMyNotifications } from '@/api/queries'
|
||||
import {
|
||||
useMarkAllNotificationsRead,
|
||||
useMarkNotificationRead,
|
||||
} from '@/api/mutations'
|
||||
import type { NotificationItem } from '@/api/client'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-side drawer показывает recent notifications для current user.
|
||||
* Rendered поверх TopBar bell click. Polled через {@link useMyNotifications}
|
||||
* (30s interval same как /reviews queue).
|
||||
*
|
||||
* <p>Каждая row показывает: channel icon → preview text → relative time →
|
||||
* unread dot. Click → either deep-link (e.g. /reviews/{draftId}) ИЛИ просто
|
||||
* mark-as-read inline. Backend providing `href` для approval-related events
|
||||
* (draft decision toast spec из TODO 7).
|
||||
*
|
||||
* <p>Footer: "Mark all as read" button когда есть unread; иначе hidden.
|
||||
*/
|
||||
export function NotificationsDrawer({ open, onClose }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const q = useMyNotifications(false, 20)
|
||||
const markOne = useMarkNotificationRead()
|
||||
const markAll = useMarkAllNotificationsRead()
|
||||
|
||||
const items = q.data?.items ?? []
|
||||
const totalUnread = q.data?.totalUnread ?? 0
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
side="right"
|
||||
widthClassName="w-full max-w-md"
|
||||
title={t('notifications.title', { defaultValue: 'Уведомления' })}
|
||||
description={
|
||||
totalUnread > 0
|
||||
? t('notifications.unreadCount', {
|
||||
count: totalUnread,
|
||||
defaultValue: `${totalUnread} непрочитанных`,
|
||||
})
|
||||
: t('notifications.allRead', { defaultValue: 'Всё прочитано' })
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{q.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{q.error && <QueryErrorState error={q.error} onRetry={() => q.refetch()} />}
|
||||
|
||||
{!q.isLoading && !q.error && items.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<BellIcon weight="duotone" size={28} />}
|
||||
title={t('notifications.empty', {
|
||||
defaultValue: 'Уведомлений пока нет',
|
||||
})}
|
||||
description={t('notifications.emptyDescription', {
|
||||
defaultValue:
|
||||
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="divide-y divide-line">
|
||||
{items.map((n) => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
item={n}
|
||||
onMarkRead={() => markOne.mutate(n.id)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{totalUnread > 0 && (
|
||||
<div className="border-t border-line pt-3 flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<CheckIcon weight="bold" size={14} />}
|
||||
onClick={() => markAll.mutate()}
|
||||
loading={markAll.isPending}
|
||||
disabled={markAll.isPending}
|
||||
>
|
||||
{t('notifications.markAllRead', {
|
||||
defaultValue: 'Прочитать все',
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationRow({
|
||||
item,
|
||||
onMarkRead,
|
||||
onClose,
|
||||
}: {
|
||||
item: NotificationItem
|
||||
onMarkRead: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const content = (
|
||||
<div className="flex items-start gap-3 py-3 group">
|
||||
{/* Unread dot — visual cue для еще-не-прочитанных. Скрыт когда read.
|
||||
aria-label чтобы screen reader озвучил статус. */}
|
||||
<span
|
||||
aria-label={
|
||||
item.read
|
||||
? t('notifications.read', { defaultValue: 'Прочитано' })
|
||||
: t('notifications.unread', { defaultValue: 'Непрочитано' })
|
||||
}
|
||||
className={
|
||||
'mt-1.5 size-2 shrink-0 rounded-full ' +
|
||||
(item.read ? 'bg-transparent' : 'bg-accent')
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={badgeVariant(item.eventType)}>
|
||||
{humanEventType(item.eventType)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="lowercase">
|
||||
{item.channel}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className={'text-cell ' + (item.read ? 'text-ink-2' : 'text-ink font-medium')}>
|
||||
{item.payloadPreview}
|
||||
</p>
|
||||
<p className="text-cap text-mute tabular-nums">
|
||||
{new Date(item.sentAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{!item.read && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMarkRead()
|
||||
}}
|
||||
aria-label={t('notifications.markRead', {
|
||||
defaultValue: 'Отметить прочитанным',
|
||||
})}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity text-mute hover:text-ink shrink-0"
|
||||
>
|
||||
<CheckIcon weight="bold" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Deep-link case: row is a Link, click closes drawer + navigates +
|
||||
// implicitly marks-as-read через server (backend should mark read on
|
||||
// resource load OR we add explicit POST here).
|
||||
if (item.href) {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => {
|
||||
if (!item.read) onMarkRead()
|
||||
onClose()
|
||||
}}
|
||||
className="block hover:bg-line/30 -mx-3 px-3 rounded-sm transition-colors"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return <li>{content}</li>
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type → Badge variant mapping. Decision-positive (approved/published)
|
||||
* → success, negative (rejected/withdrawn) → danger, neutral progress
|
||||
* (submitted/changes_requested) → info.
|
||||
*/
|
||||
function badgeVariant(
|
||||
eventType: string,
|
||||
): 'success' | 'danger' | 'info' | 'warning' | 'default' {
|
||||
if (/Approved|Published/i.test(eventType)) return 'success'
|
||||
if (/Rejected|Withdrawn/i.test(eventType)) return 'danger'
|
||||
if (/Submitted|ChangesRequested/i.test(eventType)) return 'info'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type → short russian label для chip. Server-side resolution лучше,
|
||||
* но пока mapping inline здесь — backend payloadPreview уже несёт полный
|
||||
* человеческий текст, chip это просто категория.
|
||||
*/
|
||||
function humanEventType(eventType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
RecordDraftSubmitted: 'submit',
|
||||
RecordDraftApproved: 'approved',
|
||||
RecordDraftRejected: 'rejected',
|
||||
RecordDraftWithdrawn: 'withdrawn',
|
||||
SchemaDraftSubmitted: 'schema submit',
|
||||
SchemaDraftApproved: 'schema approved',
|
||||
SchemaDraftRejected: 'schema rejected',
|
||||
SchemaDraftPublished: 'schema published',
|
||||
}
|
||||
return map[eventType] ?? eventType
|
||||
}
|
||||
@@ -581,6 +581,22 @@ i18n
|
||||
'reviews.tab.records': 'Записи',
|
||||
'reviews.tab.schemas': 'Схемы',
|
||||
'reviews.tab.mine': 'Мои',
|
||||
// === Notifications (TODO 7) ===
|
||||
'notifications.title': 'Уведомления',
|
||||
'notifications.empty': 'Уведомлений пока нет',
|
||||
'notifications.emptyDescription':
|
||||
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
|
||||
'notifications.markAllRead': 'Прочитать все',
|
||||
'notifications.markRead': 'Отметить прочитанным',
|
||||
'notifications.read': 'Прочитано',
|
||||
'notifications.unread': 'Непрочитано',
|
||||
'notifications.allRead': 'Всё прочитано',
|
||||
'notifications.unreadCount_one': '{{count}} непрочитанное',
|
||||
'notifications.unreadCount_few': '{{count}} непрочитанных',
|
||||
'notifications.unreadCount_many': '{{count}} непрочитанных',
|
||||
'notifications.newDefault': 'Новое уведомление',
|
||||
'notifications.toastDescription':
|
||||
'Откройте колокольчик чтобы увидеть подробности.',
|
||||
// Interaction state polish (TODO 2)
|
||||
'reviews.emptyDescription':
|
||||
'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.',
|
||||
@@ -1323,6 +1339,20 @@ i18n
|
||||
'reviews.tab.records': 'Records',
|
||||
'reviews.tab.schemas': 'Schemas',
|
||||
'reviews.tab.mine': 'Mine',
|
||||
// === Notifications (TODO 7) ===
|
||||
'notifications.title': 'Notifications',
|
||||
'notifications.empty': 'No notifications yet',
|
||||
'notifications.emptyDescription':
|
||||
'Decisions on your drafts and other events will appear here.',
|
||||
'notifications.markAllRead': 'Mark all read',
|
||||
'notifications.markRead': 'Mark read',
|
||||
'notifications.read': 'Read',
|
||||
'notifications.unread': 'Unread',
|
||||
'notifications.allRead': 'All read',
|
||||
'notifications.unreadCount_one': '{{count}} unread',
|
||||
'notifications.unreadCount_other': '{{count}} unread',
|
||||
'notifications.newDefault': 'New notification',
|
||||
'notifications.toastDescription': 'Open the bell to see details.',
|
||||
// Interaction state polish (TODO 2)
|
||||
'reviews.emptyDescription':
|
||||
'Queue is clear. Past decisions live in the audit log.',
|
||||
|
||||
Reference in New Issue
Block a user