diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py
index 8c3c3133..9472f2da 100644
--- a/backend/notifications/api/urls.py
+++ b/backend/notifications/api/urls.py
@@ -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'),
# Настройки каналов
diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py
index 9a47aea4..f521de2a 100644
--- a/backend/notifications/api/views.py
+++ b/backend/notifications/api/views.py
@@ -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):
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index f02b691f..1f7bb5d5 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -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 (
-
+ handleNavClick(category)}
+ >
{label}
{count > 0 && (
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts
index 553f76f3..a91bae52 100644
--- a/frontend/src/hooks/useApi.ts
+++ b/frontend/src/hooks/useApi.ts
@@ -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
+ };
}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 98610dbe..b87b444d 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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/');
diff --git a/frontend/src/lib/verbTranslations.ts b/frontend/src/lib/verbTranslations.ts
index 34b6d398..223d114d 100644
--- a/frontend/src/lib/verbTranslations.ts
+++ b/frontend/src/lib/verbTranslations.ts
@@ -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)
*/