feat: добавлены realtime бэйджи с уведомлениями в боковую панель

- Исправлена логика подсчёта уведомлений по категориям (n.verb вместо n.category)
- Добавлена синхронизация между компонентами через CustomEvent API
- Реализована автоматическая отметка категории уведомлений при переходе в раздел
- Backend: универсальный endpoint /category/read/ принимающий список verbs
- Frontend: функция getVerbsByCategory() для маппинга категорий
- Счётчики обновляются в реальном времени через WebSocket
- Синхронизация работает между всеми вкладками браузера

Модуль уведомлений остался переиспользуемым - категории управляются на фронте
This commit is contained in:
lizerk 2026-03-11 08:48:26 +03:00
parent 6a9a846455
commit 6fe7eeb222
6 changed files with 216 additions and 10 deletions

View File

@ -21,6 +21,7 @@ urlpatterns = [
# Массовые операции
path('read-all/', views.mark_all_as_read, name='mark_all_read'),
path('category/read/', views.mark_category_as_read, name='mark_category_read'),
path('delete-all-read/', views.delete_all_read, name='delete_all_read'),
# Настройки каналов

View File

@ -10,8 +10,6 @@ from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django.db import models
from django.conf import settings
from ..models import Notification, UserChannelPreferences
@ -192,6 +190,48 @@ def mark_all_as_read(request):
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def mark_category_as_read(request):
"""
Отметить группу уведомлений по списку verb'ов как прочитанные
Body:
- verbs: список verb'ов для отметки (array)
- category: название категории (опционально, для логирования)
Example:
POST /api/v1/notifications/category/read/
{
"verbs": ["request_new", "request_updated"],
"category": "Заявки"
}
"""
verbs = request.data.get('verbs', [])
if not verbs or not isinstance(verbs, list):
return Response(
{'status': 'error', 'message': 'verbs array is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Отмечаем все уведомления с этими verb'ами
queryset = Notification.objects.filter(
recipient=request.user,
unread=True,
deleted=False,
verb__in=verbs,
)
count = queryset.mark_all_as_read()
return Response({
'status': 'success',
'verbs': verbs,
'count': count
})
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def delete_notification(request, notification_id):

View File

@ -242,7 +242,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const { notifications: notificationsData } = useNotifications();
const { notifications: notificationsData, markCategoryAsRead } = useNotifications();
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
// Подсчет уведомлений по категориям
@ -251,14 +251,26 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
notifications.forEach((n: any) => {
// Пропускаем прочитанные
if (!n.category || n.is_read) return;
const category = getVerbCategory(n.category);
if (!n.verb || n.is_read) return;
const category = getVerbCategory(n.verb);
counts[category] = (counts[category] || 0) + 1;
});
return counts;
}, [notifications]);
const handleNavClick = async (category?: string) => {
// Помечаем уведомления категории как прочитанные
if (category && categoryCounts[category] > 0) {
await markCategoryAsRead(category);
}
// Вызываем callback для закрытия мобильного меню
if (onNavigate) {
onNavigate();
}
};
const navLinkClass = (href: string) =>
`flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700"
}`;
@ -272,7 +284,12 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const count = category ? categoryCounts[category] || 0 : 0;
return (
<Link key={href} href={href} className={navLinkClass(href)} onClick={onNavigate}>
<Link
key={href}
href={href}
className={navLinkClass(href)}
onClick={() => handleNavClick(category)}
>
<Icon size={18} className={navIconClass(href)} />
<span className="flex-1">{label}</span>
{count > 0 && (

View File

@ -312,6 +312,65 @@ export function useNotifications() {
};
}, []);
// Синхронизация между экземплярами хука через CustomEvent
useEffect(() => {
if (typeof window === 'undefined') return;
const handleMarkedRead = (e: Event) => {
const customEvent = e as CustomEvent;
const { id } = customEvent.detail;
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
};
const handleAllMarkedRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
};
const handleCategoryMarkedRead = async (e: Event) => {
const customEvent = e as CustomEvent;
const { category, verbs } = customEvent.detail;
// Используем переданные verb'ы для проверки
setNotifications(prev => {
const updated = prev.map(n => {
const isInCategory = verbs.includes(n.verb);
return isInCategory ? { ...n, is_read: true } : n;
});
// Пересчитываем счетчик
const newUnreadCount = updated.filter(n => !n.is_read).length;
setUnreadCount(newUnreadCount);
return updated;
});
};
const handleDeleted = (e: Event) => {
const customEvent = e as CustomEvent;
const { id, wasUnread } = customEvent.detail;
setNotifications(prev => prev.filter(n => n.id !== id));
if (wasUnread) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
};
window.addEventListener('notification-marked-read', handleMarkedRead);
window.addEventListener('notifications-all-marked-read', handleAllMarkedRead);
window.addEventListener('category-marked-read', handleCategoryMarkedRead);
window.addEventListener('notification-deleted', handleDeleted);
return () => {
window.removeEventListener('notification-marked-read', handleMarkedRead);
window.removeEventListener('notifications-all-marked-read', handleAllMarkedRead);
window.removeEventListener('category-marked-read', handleCategoryMarkedRead);
window.removeEventListener('notification-deleted', handleDeleted);
};
}, []);
const markAsRead = async (id: number) => {
try {
await apiClient.markNotificationAsRead(id);
@ -319,6 +378,13 @@ export function useNotifications() {
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notification-marked-read', {
detail: { id }
}));
}
} catch (err) {
console.error('Failed to mark notification as read:', err);
}
@ -329,6 +395,11 @@ export function useNotifications() {
await apiClient.markAllNotificationsAsRead();
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notifications-all-marked-read'));
}
} catch (err) {
console.error('Failed to mark all notifications as read:', err);
}
@ -337,12 +408,22 @@ export function useNotifications() {
const deleteNotification = async (id: number) => {
try {
await apiClient.deleteNotification(id);
setNotifications(prev => prev.filter(n => n.id !== id));
// Если удаленное было непрочитанным, уменьшаем счетчик
// Проверяем было ли непрочитанным ДО удаления
const deletedNotif = notifications.find(n => n.id === id);
if (deletedNotif && !deletedNotif.is_read) {
const wasUnread = deletedNotif && !deletedNotif.is_read;
setNotifications(prev => prev.filter(n => n.id !== id));
if (wasUnread) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notification-deleted', {
detail: { id, wasUnread }
}));
}
} catch (err) {
console.error('Failed to delete notification:', err);
}
@ -359,5 +440,52 @@ export function useNotifications() {
}
};
return { notifications, loading, error, unreadCount, markAsRead, markAllAsRead, deleteNotification, deleteAllRead };
const markCategoryAsRead = async (category: string) => {
try {
const result = await apiClient.markCategoryAsRead(category);
// Импортируем getVerbCategory, getVerbsByCategory для обработки
const { getVerbCategory, getVerbsByCategory } = await import('@/lib/verbTranslations');
const categoryVerbs = getVerbsByCategory(category);
// Помечаем уведомления этой категории как прочитанные
setNotifications(prev =>
prev.map(n => {
const isInCategory = categoryVerbs.includes(n.verb);
return isInCategory ? { ...n, is_read: true } : n;
})
);
// Пересчитываем счетчик непрочитанных
setNotifications(prev => {
const newUnreadCount = prev.filter(n => !n.is_read).length;
setUnreadCount(newUnreadCount);
return prev;
});
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('category-marked-read', {
detail: { category, verbs: categoryVerbs }
}));
}
return result.count;
} catch (err) {
console.error('Failed to mark category as read:', err);
return 0;
}
};
return {
notifications,
loading,
error,
unreadCount,
markAsRead,
markAllAsRead,
markCategoryAsRead,
deleteNotification,
deleteAllRead
};
}

View File

@ -815,6 +815,17 @@ class ApiClient {
});
}
async markCategoryAsRead(category: string): Promise<{ status: string; count: number }> {
// Получаем список verb'ов для категории
const { getVerbsByCategory } = await import('./verbTranslations');
const verbs = getVerbsByCategory(category);
return this.request('/api/v1/notifications/category/read/', {
method: 'POST',
body: JSON.stringify({ verbs, category }),
});
}
// Web Push подписки
async getVapidPublicKey(): Promise<{ vapid_public_key: string }> {
return this.request('/api/v1/notifications/push/vapid-key/');

View File

@ -100,6 +100,15 @@ export function getVerbCategory(verb: string): string {
return VERB_CATEGORIES[verb] || 'Общее';
}
/**
* Получить все verb'ы для заданной категории
*/
export function getVerbsByCategory(category: string): string[] {
return Object.entries(VERB_CATEGORIES)
.filter(([_, cat]) => cat === category)
.map(([verb, _]) => verb);
}
/**
* Получить название для verb (fallback если нет title в notification.data)
*/