+```
+
+## Workflow (Как это работает)
+
+### 1. Инициализация при загрузке страницы
+
+```
+User opens page
+ ↓
+useWebPush hook initialized
+ ↓
+Check browser support (Service Worker + Push API + Notification API)
+ ↓
+✅ Supported → Continue
+ ↓
+Fetch VAPID public key from server
+ ↓
+Register Service Worker (/sw.js)
+ ↓
+Check current subscription (PushManager.getSubscription())
+ ↓
+isSubscribed = (subscription !== null)
+```
+
+### 2. Подписка на уведомления (User action)
+
+```
+User clicks "Включить уведомления"
+ ↓
+Check permission:
+ - 'default' → Request permission (Notification.requestPermission())
+ - 'denied' → Show error toast
+ - 'granted' → Continue
+ ↓
+Create push subscription (PushManager.subscribe())
+ - userVisibleOnly: true
+ - applicationServerKey: VAPID key
+ ↓
+Serialize subscription (endpoint + keys.p256dh + keys.auth)
+ ↓
+Send to backend (POST /api/v1/notifications/push/subscribe/)
+ ↓
+Backend saves to WebPushDevice table
+ ↓
+✅ Show success toast
+```
+
+### 3. Получение push уведомления
+
+```
+Backend sends push notification (django-push-notifications)
+ ↓
+Browser receives push event (даже если вкладка закрыта)
+ ↓
+Service Worker intercepts 'push' event
+ ↓
+Parse payload (JSON.parse(event.data.text()))
+ ↓
+Show native notification (registration.showNotification())
+ ↓
+User clicks notification
+ ↓
+Service Worker 'notificationclick' event
+ ↓
+Focus or open window with notification URL
+```
+
+### 4. Обновление списка уведомлений
+
+```
+NotificationCenter component mounts
+ ↓
+useNotifications() hook called
+ ↓
+Fetch notifications from API (GET /api/v1/notifications/)
+ ↓
+Display in dropdown with unread badge
+ ↓
+User clicks notification
+ ↓
+Mark as read (POST /api/v1/notifications/{id}/mark_read/)
+ ↓
+Navigate to action_url
+```
+
+## Интеграция в существующие компоненты
+
+### AppShell.tsx
+
+**Изменения:**
+- Импортирован `NotificationCenter`
+- Заменены статичные кнопки Bell на ``
+- Добавлено в desktop версию (header)
+- Добавлено в mobile версию (нижняя панель)
+
+**До:**
+```tsx
+
+```
+
+**После:**
+```tsx
+
+```
+
+### Providers.tsx
+
+**Изменения:**
+- Импортирован `Toaster` из Sonner
+- Добавлен `` в провайдеры
+
+```tsx
+import { Toaster } from 'sonner';
+
+
+```
+
+## Тестирование
+
+### Тестовый сценарий 1: Подписка на уведомления
+
+1. Открыть приложение в браузере
+2. Кликнуть на иконку Bell в header
+3. Кликнуть на "Настройки" (⚙️)
+4. Кликнуть "Включить" в разделе Push уведомления
+5. Браузер покажет запрос разрешения → Разрешить
+6. Toast: "Подписка на уведомления активирована"
+7. В настройках должно быть "Включены" и кнопка "Отключить"
+
+### Тестовый сценарий 2: Получение push уведомления
+
+**Из Django shell:**
+```python
+from notifications.services import NotificationService
+from notifications.models import Notification
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+user = User.objects.get(username='testuser')
+notification = Notification.objects.filter(recipient=user).first()
+
+# Отправить push
+count = NotificationService.send_web_push_notification(notification)
+print(f"Отправлено: {count}")
+```
+
+**Проверки:**
+1. Нативное уведомление должно появиться (даже если вкладка закрыта)
+2. При клике должна открыться нужная страница
+3. В NotificationCenter должно появиться уведомление
+4. Badge должен увеличиться
+
+### Тестовый сценарий 3: Toast уведомления
+
+**В любом компоненте:**
+```tsx
+import { toast } from 'sonner';
+
+
+```
+
+**Проверки:**
+1. Toast появляется в правом верхнем углу
+2. Автоматически исчезает через 4 секунды
+3. Можно закрыть свайпом вправо
+4. При наведении пауза на таймере
+
+### Тестовый сценарий 4: NotificationCenter UI
+
+1. Открыть NotificationCenter
+2. Должен показаться список уведомлений
+3. Непрочитанные - с синим фоном и точкой
+4. При клике на уведомление:
+ - Отмечается как прочитанное
+ - Badge уменьшается
+ - Происходит навигация (если есть URL)
+5. "Прочитать все" - все уведомления становятся прочитанными
+
+## Браузерная совместимость
+
+### Web Push API
+
+| Браузер | Desktop | Mobile | Примечания |
+|---------|---------|--------|------------|
+| Chrome | ✅ 50+ | ✅ 50+ | Полная поддержка |
+| Firefox | ✅ 44+ | ✅ 48+ | Полная поддержка |
+| Safari | ✅ 16+ | ❌ | iOS не поддерживает |
+| Edge | ✅ 79+ | ✅ | На базе Chromium |
+| Opera | ✅ 37+ | ✅ | На базе Chromium |
+
+**Важно:** iOS Safari НЕ поддерживает Web Push (даже в iOS 16+).
+- Для iOS нужно использовать нативное приложение + APNS
+
+### Fallback стратегия
+
+```tsx
+const { isSupported, isSubscribed } = useWebPush();
+
+if (!isSupported) {
+ // Показываем альтернативу
+ return Push уведомления не поддерживаются вашим браузером
;
+}
+```
+
+## Production checklist
+
+- [ ] **HTTPS обязателен** - Service Worker требует HTTPS
+- [ ] **VAPID ключи** - проверить что есть в production settings
+- [ ] **Service Worker scope** - проверить что sw.js доступен в корне
+- [ ] **Тестирование на реальных устройствах**
+ - Desktop Chrome/Firefox
+ - Android Chrome
+ - Desktop Safari (если поддержка добавится)
+- [ ] **Мониторинг ошибок** - логировать ошибки Service Worker
+- [ ] **Rate limiting** - защита от спама push уведомлений
+
+## Конфигурация
+
+### Environment variables (Backend)
+
+```bash
+# В .env или settings.py
+VAPID_PUBLIC_KEY=BMTitZy9r4ygYJBgGdaZuCkb7rwR7iHLJv...
+VAPID_PRIVATE_KEY=
+VAPID_ADMIN_EMAIL=admin@example.com
+```
+
+### Next.js конфигурация
+
+**`next.config.ts`** - добавить headers для Service Worker (если нужно):
+```typescript
+async headers() {
+ return [
+ {
+ source: '/sw.js',
+ headers: [
+ {
+ key: 'Service-Worker-Allowed',
+ value: '/'
+ }
+ ]
+ }
+ ];
+}
+```
+
+## Troubleshooting
+
+### Проблема: Service Worker не регистрируется
+
+**Возможные причины:**
+- Приложение запущено на HTTP (нужен HTTPS или localhost)
+- sw.js недоступен (404)
+- Браузер не поддерживает Service Worker
+
+**Решение:**
+```bash
+# Проверить доступность sw.js
+curl http://localhost:3000/sw.js
+
+# В консоли браузера
+navigator.serviceWorker.getRegistrations().then(console.log)
+```
+
+### Проблема: Push подписка не создается
+
+**Возможные причины:**
+- Разрешение denied
+- VAPID ключ неверный
+- PushManager не поддерживается
+
+**Решение:**
+```javascript
+// В консоли браузера
+console.log('Permission:', Notification.permission);
+console.log('PushManager supported:', 'PushManager' in window);
+
+// Проверить VAPID ключ
+fetch('/api/v1/notifications/push/vapid-key/')
+ .then(r => r.json())
+ .then(console.log);
+```
+
+### Проблема: Уведомления не приходят
+
+**Возможные причины:**
+- Подписка истекла (endpoint недействителен)
+- Backend не может отправить (VAPID ошибка)
+- Service Worker не активен
+
+**Решение:**
+```python
+# Django shell - проверить активные устройства
+from push_notifications.models import WebPushDevice
+devices = WebPushDevice.objects.filter(user=user, active=True)
+print(devices.count())
+
+# Отправить тестовое уведомление
+devices.send_message('{"title": "Test", "body": "Hello"}')
+```
+
+## Дальнейшие улучшения
+
+### Функциональность
+
+- [ ] **Фильтры уведомлений** - по категориям (заявки, сообщения, календарь)
+- [ ] **Группировка** - по дате или типу
+- [ ] **Действия в уведомлении** - прямо из push (approve/reject)
+- [ ] **Звук** - настраиваемый звук для разных типов
+- [ ] **Badge на иконке приложения** - счетчик непрочитанных
+
+### UX/UI
+
+- [ ] **Skeleton loading** - вместо "Загрузка..."
+- [ ] **Infinite scroll** - для списка уведомлений
+- [ ] **Поиск** - по уведомлениям
+- [ ] **Настройки по типам** - включить/выключить для каждого типа
+- [ ] **Темная тема** - поддержка dark mode
+
+### Производительность
+
+- [ ] **WebSocket real-time** - мгновенное обновление списка
+- [ ] **Service Worker cache** - кеширование данных уведомлений
+- [ ] **Prefetch** - предзагрузка страниц из action_url
+
+### Аналитика
+
+- [ ] **Отслеживание открытий** - сколько push уведомлений открыто
+- [ ] **Conversion tracking** - какие уведомления ведут к действиям
+- [ ] **A/B тестирование** - разные тексты/форматы
+
+## Заключение
+
+✅ **Система уведомлений полностью реализована:**
+
+**Backend:**
+- django-push-notifications 3.1.0
+- WebPushDevice модель
+- VAPID ключи настроены
+- API endpoints готовы
+
+**Frontend:**
+- Service Worker для Web Push
+- NotificationCenter UI компонент
+- useWebPush React хук
+- Sonner для toast уведомлений
+- Интеграция в AppShell
+
+**Результат:**
+- Нативные push уведомления (работают даже когда вкладка закрыта)
+- Красивый UI с badge и историей
+- Настройки подписок
+- Респонсивный дизайн
+- TypeScript типизация
+
+**Готово к использованию!** 🎉
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3c5690a5..8f475e40 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,7 +15,8 @@
"next": "16.1.6",
"react": "19.2.3",
"react-big-calendar": "^1.19.4",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "sonner": "^2.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -5821,6 +5822,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c9c94c86..b7b27ab5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,7 +17,8 @@
"next": "16.1.6",
"react": "19.2.3",
"react-big-calendar": "^1.19.4",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "sonner": "^2.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 00000000..ec07d267
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,166 @@
+/**
+ * Service Worker для обработки Web Push уведомлений
+ *
+ * Этот файл должен находиться в /public/ для Next.js.
+ * Service Worker перехватывает push-события от браузера и показывает
+ * нативные уведомления даже когда страница закрыта.
+ */
+
+const SW_VERSION = '2.0.0';
+
+// Настройки по умолчанию для уведомлений
+const DEFAULT_NOTIFICATION_OPTIONS = {
+ icon: '/logo.png',
+ badge: '/logo.png',
+ vibrate: [100, 50, 100],
+ requireInteraction: false,
+ renotify: true,
+};
+
+/**
+ * Событие установки Service Worker
+ */
+self.addEventListener('install', (event) => {
+ console.log('[SW] Service Worker installing, version:', SW_VERSION);
+ // Немедленно активировать новую версию
+ self.skipWaiting();
+});
+
+/**
+ * Событие активации Service Worker
+ */
+self.addEventListener('activate', (event) => {
+ console.log('[SW] Service Worker activated, version:', SW_VERSION);
+ // Захватить все клиенты без перезагрузки
+ event.waitUntil(self.clients.claim());
+});
+
+/**
+ * Обработка входящих push-уведомлений
+ *
+ * Формат данных с сервера:
+ * {
+ * "title": "Заголовок уведомления",
+ * "body": "Текст уведомления",
+ * "icon": "/logo.png",
+ * "badge": "/logo.png",
+ * "tag": "notification-id",
+ * "data": {
+ * "url": "/path/to/page",
+ * "notification_id": 123,
+ * "category": "requests",
+ * }
+ * }
+ */
+self.addEventListener('push', (event) => {
+ console.log('[SW] Push received:', event);
+
+ let notificationData = {
+ title: 'Новое уведомление',
+ body: '',
+ ...DEFAULT_NOTIFICATION_OPTIONS,
+ data: {}
+ };
+
+ if (event.data) {
+ try {
+ const payload = event.data.json();
+ console.log('[SW] Push payload:', payload);
+
+ notificationData = {
+ ...notificationData,
+ title: payload.title || notificationData.title,
+ body: payload.body || payload.message || '',
+ icon: payload.icon || notificationData.icon,
+ badge: payload.badge || notificationData.badge,
+ tag: payload.tag || `notification-${Date.now()}`,
+ data: payload.data || {}
+ };
+
+ // Дополнительные опции
+ if (payload.image) {
+ notificationData.image = payload.image;
+ }
+ if (payload.actions) {
+ notificationData.actions = payload.actions;
+ }
+ if (payload.requireInteraction !== undefined) {
+ notificationData.requireInteraction = payload.requireInteraction;
+ }
+
+ } catch (e) {
+ console.log('[SW] Push data is not JSON, using as text');
+ notificationData.body = event.data.text();
+ }
+ }
+
+ // Показываем уведомление
+ const { title, ...options } = notificationData;
+
+ event.waitUntil(
+ self.registration.showNotification(title, options)
+ .then(() => {
+ console.log('[SW] Notification shown:', title);
+ })
+ .catch((error) => {
+ console.error('[SW] Failed to show notification:', error);
+ })
+ );
+});
+
+/**
+ * Обработка клика по уведомлению
+ */
+self.addEventListener('notificationclick', (event) => {
+ console.log('[SW] Notification clicked:', event.notification.tag);
+
+ event.notification.close();
+
+ // Получаем URL из данных уведомления
+ const urlToOpen = event.notification.data?.url || '/';
+
+ // Открываем или фокусируемся на странице
+ event.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true })
+ .then((windowClients) => {
+ // Ищем уже открытую вкладку с нашим сайтом
+ for (let client of windowClients) {
+ if (client.url.includes(self.location.origin) && 'focus' in client) {
+ return client.focus().then((client) => {
+ // Отправляем сообщение для навигации
+ if ('navigate' in client) {
+ return client.navigate(urlToOpen);
+ }
+ return client.postMessage({
+ type: 'NOTIFICATION_CLICK',
+ url: urlToOpen,
+ notificationId: event.notification.data?.notification_id
+ });
+ });
+ }
+ }
+
+ // Если открытой вкладки нет, открываем новую
+ if (clients.openWindow) {
+ return clients.openWindow(urlToOpen);
+ }
+ })
+ );
+});
+
+/**
+ * Обработка закрытия уведомления
+ */
+self.addEventListener('notificationclose', (event) => {
+ console.log('[SW] Notification closed:', event.notification.tag);
+
+ // Можно отправить статистику на сервер
+ if (event.notification.data?.notification_id) {
+ // fetch('/api/v1/notifications/stats/closed/', {
+ // method: 'POST',
+ // body: JSON.stringify({ id: event.notification.data.notification_id })
+ // });
+ }
+});
+
+console.log('[SW] Service Worker loaded, version:', SW_VERSION);
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index 568853b1..bae80966 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -8,6 +8,7 @@ import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
+import { NotificationCenter } from "@/components/NotificationCenter";
import { EventModal } from "@/components/EventModal";
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
@@ -129,9 +130,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
-
+