Files
ospab.host/ospabhost/frontend/src/pages/dashboard/notifications.tsx
2025-12-31 19:59:43 +03:00

377 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from '../../i18n';
import {
getNotifications,
markAsRead,
markAllAsRead,
deleteNotification,
deleteAllRead,
requestPushPermission,
type Notification
} from '../../services/notificationService';
const NotificationsPage = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const [pushEnabled, setPushEnabled] = useState(false);
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
const { locale } = useTranslation();
const isEn = locale === 'en';
const checkPushPermission = () => {
if ('Notification' in window) {
const permission = Notification.permission;
setPushPermission(permission);
setPushEnabled(permission === 'granted');
}
};
const loadNotifications = useCallback(async () => {
setLoading(true);
try {
const response = await getNotifications({
page: 1,
limit: 50,
unreadOnly: filter === 'unread'
});
// Проверяем, что response имеет правильную структуру
if (response && Array.isArray(response.notifications)) {
setNotifications(response.notifications);
} else {
console.error('Неверный формат ответа от сервера:', response);
setNotifications([]);
}
} catch (error) {
console.error('Ошибка загрузки уведомлений:', error);
setNotifications([]);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
loadNotifications();
checkPushPermission();
}, [loadNotifications]);
const handleMarkAsRead = async (id: number) => {
try {
await markAsRead(id);
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
} catch (error) {
console.error('Ошибка пометки прочитанным:', error);
}
};
const handleMarkAllAsRead = async () => {
try {
await markAllAsRead();
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
} catch (error) {
console.error('Ошибка пометки всех прочитанными:', error);
}
};
const handleDelete = async (id: number) => {
try {
await deleteNotification(id);
setNotifications((prev) => prev.filter((n) => n.id !== id));
} catch (error) {
console.error('Ошибка удаления уведомления:', error);
}
};
const handleDeleteAllRead = async () => {
if (!window.confirm(isEn ? 'Delete all read notifications?' : 'Удалить все прочитанные уведомления?')) return;
try {
await deleteAllRead();
setNotifications((prev) => prev.filter((n) => !n.isRead));
} catch (error) {
console.error('Ошибка удаления прочитанных:', error);
}
};
const handleEnablePush = async () => {
const success = await requestPushPermission();
if (success) {
setPushEnabled(true);
setPushPermission('granted');
alert(isEn ? 'Push notifications enabled!' : 'Push-уведомления успешно подключены!');
} else {
alert(isEn ? 'Failed to enable push notifications. Check your browser permissions.' : 'Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
// Обновляем состояние на случай, если пользователь отклонил
checkPushPermission();
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString(isEn ? 'en-US' : 'ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const groupNotificationsByDate = (notifications: Notification[]) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const todayLabel = isEn ? 'Today' : 'Сегодня';
const yesterdayLabel = isEn ? 'Yesterday' : 'Вчера';
const weekLabel = isEn ? 'Last 7 days' : 'За последние 7 дней';
const earlierLabel = isEn ? 'Earlier' : 'Ранее';
const groups: Record<string, Notification[]> = {
[todayLabel]: [],
[yesterdayLabel]: [],
[weekLabel]: [],
[earlierLabel]: []
};
notifications.forEach((notification) => {
const notifDate = new Date(notification.createdAt);
const notifDay = new Date(notifDate.getFullYear(), notifDate.getMonth(), notifDate.getDate());
if (notifDay.getTime() === today.getTime()) {
groups[todayLabel].push(notification);
} else if (notifDay.getTime() === yesterday.getTime()) {
groups[yesterdayLabel].push(notification);
} else if (notifDate >= weekAgo) {
groups[weekLabel].push(notification);
} else {
groups[earlierLabel].push(notification);
}
});
return groups;
};
const unreadCount = notifications.filter((n) => !n.isRead).length;
const groupedNotifications = groupNotificationsByDate(notifications);
return (
<div className="p-4 lg:p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-6">{isEn ? 'Notifications' : 'Уведомления'}</h1>
{/* Панель действий */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Фильтры */}
<div className="flex gap-2">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
filter === 'all'
? 'bg-ospab-primary text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{isEn ? 'All' : 'Все'} ({notifications.length})
</button>
<button
onClick={() => setFilter('unread')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
filter === 'unread'
? 'bg-ospab-primary text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{isEn ? 'Unread' : 'Непрочитанные'} ({unreadCount})
</button>
</div>
{/* Действия */}
<div className="flex gap-2">
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors"
>
{isEn ? 'Mark all as read' : 'Прочитать все'}
</button>
)}
{notifications.some((n) => n.isRead) && (
<button
onClick={handleDeleteAllRead}
className="px-4 py-2 rounded-md text-sm font-medium bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
>
{isEn ? 'Delete read' : 'Удалить прочитанные'}
</button>
)}
</div>
</div>
{/* Push-уведомления */}
{!pushEnabled && 'Notification' in window && pushPermission !== 'denied' && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md flex items-start gap-3">
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<div className="flex-1">
<h3 className="text-sm font-semibold text-blue-900 mb-1">
{isEn ? 'Enable Push Notifications' : 'Подключите Push-уведомления'}
</h3>
<p className="text-sm text-blue-700 mb-3">
{isEn ? 'Get instant notifications on your device for important events' : 'Получайте мгновенные уведомления на компьютер или телефон при важных событиях'}
</p>
<button
onClick={handleEnablePush}
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{isEn ? 'Enable notifications' : 'Включить уведомления'}
</button>
</div>
</div>
)}
{/* Уведомления заблокированы */}
{pushPermission === 'denied' && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md flex items-start gap-3">
<svg className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-900 mb-1">
Push-уведомления заблокированы
</h3>
<p className="text-sm text-red-700">
{isEn ? 'You have blocked notifications for this site. To enable them, allow notifications in your browser settings.' : 'Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера.'}
</p>
<p className="text-xs text-red-600 mt-2">
{isEn
? <>Chrome/Edge: Click the lock icon to the left of the address bar Notifications Allow<br/>Firefox: Settings Privacy & Security Permissions Notifications Settings</>
: <>Chrome/Edge: Нажмите на иконку замка слева от адресной строки Уведомления Разрешить<br/>Firefox: Настройки Приватность и защита Разрешения Уведомления Настройки</>
}
</p>
</div>
</div>
)}
</div>
{/* Список уведомлений */}
{loading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary"></div>
</div>
) : notifications.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<svg className="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">{isEn ? 'No notifications' : 'Нет уведомлений'}</h3>
<p className="text-gray-600">
{filter === 'unread'
? (isEn ? 'All notifications are read' : 'Все уведомления прочитаны')
: (isEn ? 'You have no notifications yet' : 'У вас пока нет уведомлений')}
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedNotifications).map(([groupName, groupNotifications]) => {
if (groupNotifications.length === 0) return null;
return (
<div key={groupName}>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{groupName}
</h2>
<div className="space-y-2">
{groupNotifications.map((notification) => (
<div
key={notification.id}
className={`bg-white rounded-lg shadow-sm border-l-4 overflow-hidden transition-all ${
notification.isRead ? 'border-transparent' : 'border-ospab-primary'
}`}
>
<div className="p-4">
<div className="flex items-start gap-4">
{/* Цветовой индикатор */}
<div className={`flex-shrink-0 w-3 h-3 rounded-full mt-2 ${
notification.color === 'green' ? 'bg-green-500' :
notification.color === 'blue' ? 'bg-blue-500' :
notification.color === 'orange' ? 'bg-orange-500' :
notification.color === 'red' ? 'bg-red-500' :
notification.color === 'purple' ? 'bg-purple-500' :
'bg-gray-500'
}`}></div>
{/* Контент */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className={`text-base font-semibold ${notification.isRead ? 'text-gray-800' : 'text-gray-900'}`}>
{notification.title}
</h3>
<p className="text-gray-600 mt-1">
{notification.message}
</p>
<p className="text-sm text-gray-400 mt-2">
{formatDate(notification.createdAt)}
</p>
</div>
{/* Действия */}
<div className="flex gap-2">
{!notification.isRead && (
<button
onClick={() => handleMarkAsRead(notification.id)}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="Пометить прочитанным"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
)}
<button
onClick={() => handleDelete(notification.id)}
className="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="Удалить"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{/* Ссылка для перехода */}
{notification.actionUrl && (
<Link
to={notification.actionUrl}
className="inline-block mt-3 text-sm text-ospab-primary hover:underline"
>
Перейти
</Link>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default NotificationsPage;