feat: добавлены realtime бэйджи с уведомлениями в боковую панель
- Исправлена логика подсчёта уведомлений по категориям (n.verb вместо n.category) - Добавлена синхронизация между компонентами через CustomEvent API - Реализована автоматическая отметка категории уведомлений при переходе в раздел - Backend: универсальный endpoint /category/read/ принимающий список verbs - Frontend: функция getVerbsByCategory() для маппинга категорий - Счётчики обновляются в реальном времени через WebSocket - Синхронизация работает между всеми вкладками браузера Модуль уведомлений остался переиспользуемым - категории управляются на фронте
This commit is contained in:
parent
6a9a846455
commit
6fe7eeb222
@ -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'),
|
||||
|
||||
# Настройки каналов
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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/');
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user