feat(notifications+a11y): центр уведомлений /notifications, skip-link, axe smoke
Шаг 2.5 плана production-готовности: - страница уведомлений (фильтр, догрузка, прочтение) + ссылки из dropdown/мобильного меню - общие хелперы notification-utils (дедупликация dropdown) - skip-link + id=content на main - axe smoke-тесты (0 critical/serious), исправлены 2 нарушения в новой странице - 3 новых теста (107/107), i18n отложен по решению пользователя
This commit is contained in:
@@ -63,6 +63,7 @@ const GenerationHistoryPage = safeLazy(() => import("@/pages/documents/Generatio
|
||||
const AssetsPage = safeLazy(() => import("@/pages/documents/AssetsPage").then(m => ({ default: m.AssetsPage })));
|
||||
const PackagesPage = safeLazy(() => import("@/pages/documents/PackagesPage").then(m => ({ default: m.PackagesPage })));
|
||||
const ChatPage = safeLazy(() => import("@/pages/Chat"));
|
||||
const NotificationsPage = safeLazy(() => import("@/pages/NotificationsPage"));
|
||||
const HomePage = safeLazy(() => import("@/pages/Home"));
|
||||
const DashboardPage = safeLazy(() => import("@/pages/DashboardPage"));
|
||||
const CalendarPage = safeLazy(() => import("@/pages/CalendarPage"));
|
||||
@@ -91,6 +92,7 @@ const ROUTE_TITLES: Array<[RegExp, string]> = [
|
||||
[/^\/med/, 'MedSchedule'],
|
||||
[/^\/gps/, 'GPS-мониторинг'],
|
||||
[/^\/chat/, 'Чат'],
|
||||
[/^\/notifications/, 'Уведомления'],
|
||||
[/^\/users/, 'Пользователи'],
|
||||
[/^\/forms\/[^/]+\/tasks/, 'Задачи'],
|
||||
[/^\/forms/, 'Формы'],
|
||||
@@ -173,6 +175,7 @@ function Router() { return (
|
||||
<Route path="/documents/packages" component={PackagesPage} />
|
||||
<Route path="/chat/:conversationId" component={ChatPage} />
|
||||
<Route path="/chat" component={ChatPage} />
|
||||
<Route path="/notifications" component={NotificationsPage} />
|
||||
<Route path="/share-target" component={ShareTarget} />
|
||||
<Route path="/qr/:templateKey/:objectId?" component={QrHandler} />
|
||||
<Route path="/forgot-password" component={ForgotPassword} />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Bookmark, User, Folder, Menu, FileText, Table2, CheckSquare, BarChart3, Layers, Users, Settings, MessageSquare, Home, Download, Smartphone, PiggyBank, MapPin } from 'lucide-react';
|
||||
import { Bookmark, User, Folder, Menu, FileText, Table2, CheckSquare, BarChart3, Layers, Users, Settings, MessageSquare, Home, Download, Smartphone, PiggyBank, MapPin, Bell } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useBookmarkFolders, useBookmarksByFolder } from '@/hooks/useBookmarks';
|
||||
import { CreateBookmarkModal } from '@/components/CreateBookmarkModal';
|
||||
@@ -356,6 +356,12 @@ export function MobileBottomNav() {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-52 mb-2">
|
||||
<Link href="/notifications">
|
||||
<DropdownMenuItem className="text-xs">
|
||||
<Bell className="w-3.5 h-3.5 mr-2" />
|
||||
Уведомления
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href="/users">
|
||||
<DropdownMenuItem className="text-xs">
|
||||
<Users className="w-3.5 h-3.5 mr-2" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useLocation } from 'wouter';
|
||||
import { format } from 'date-fns';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { useWebNotifications } from '@/hooks/useWebNotifications';
|
||||
@@ -21,6 +22,7 @@ interface NotificationsDropdownProps {
|
||||
|
||||
export const NotificationsDropdown = ({ collapsed = false }: NotificationsDropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [, setLocation] = useLocation();
|
||||
const [replyingTo, setReplyingTo] = useState<AppNotification | null>(null);
|
||||
const [replyMessage, setReplyMessage] = useState('');
|
||||
const previousNotificationsRef = useRef<AppNotification[]>([]);
|
||||
@@ -322,24 +324,21 @@ export const NotificationsDropdown = ({ collapsed = false }: NotificationsDropdo
|
||||
</>
|
||||
)}
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8"
|
||||
onClick={() => {
|
||||
// TODO: Переход на страницу всех уведомлений
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Посмотреть все
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setLocation('/notifications');
|
||||
}}
|
||||
data-testid="link-all-notifications"
|
||||
>
|
||||
Все уведомления
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -1451,6 +1451,23 @@ interface SidebarProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip-link: первая фокусируемая ссылка страницы, визуально скрыта до фокуса.
|
||||
* Ведёт на основной контент (main#content), чтобы пользователи клавиатуры
|
||||
* и скринридеров могли пропустить навигацию. position:fixed при фокусе —
|
||||
* не влияет на layout ни на desktop, ни на мобильном.
|
||||
*/
|
||||
function SkipToContentLink() {
|
||||
return (
|
||||
<a
|
||||
href="#content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
Перейти к содержимому
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ children }: SidebarProps) {
|
||||
const { user, logout, isOfflineSession, isExpiredOffline } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
@@ -1476,6 +1493,7 @@ export function Sidebar({ children }: SidebarProps) {
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed: true, setCollapsed: handleCollapse }}>
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
<SkipToContentLink />
|
||||
{!isOnline && (
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-warning/10 border-b border-warning/30 shrink-0 z-10">
|
||||
<WifiOff className="w-3.5 h-3.5 text-warning shrink-0" aria-hidden="true" />
|
||||
@@ -1488,7 +1506,7 @@ export function Sidebar({ children }: SidebarProps) {
|
||||
)}
|
||||
<OfflineSyncBanner />
|
||||
{/* pb = высота MobileBottomNav (h-12) + max(12px, safe-area-inset-bottom), чтобы контент не уходил под навигацию */}
|
||||
<main tabIndex={-1} className="flex-1 overflow-auto pb-[calc(3rem+max(12px,env(safe-area-inset-bottom)))] outline-none">
|
||||
<main id="content" tabIndex={-1} className="flex-1 overflow-auto pb-[calc(3rem+max(12px,env(safe-area-inset-bottom)))] outline-none">
|
||||
{children}
|
||||
</main>
|
||||
<MobileBottomNav />
|
||||
@@ -1500,7 +1518,8 @@ export function Sidebar({ children }: SidebarProps) {
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed, setCollapsed: handleCollapse }}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<aside
|
||||
<SkipToContentLink />
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col bg-background border-r border-border transition-[width] duration-150 shrink-0",
|
||||
collapsed ? "w-11" : "w-48"
|
||||
@@ -1691,7 +1710,7 @@ export function Sidebar({ children }: SidebarProps) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<main tabIndex={-1} className="flex-1 flex flex-col overflow-hidden bg-background outline-none">
|
||||
<main id="content" tabIndex={-1} className="flex-1 flex flex-col overflow-hidden bg-background outline-none">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useLocation } from 'wouter';
|
||||
import { format } from 'date-fns';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { useNotifications, AppNotification, CHAT_EVENT_TYPES } from '@/hooks/useNotifications';
|
||||
import { useNotifications, AppNotification } from '@/hooks/useNotifications';
|
||||
import {
|
||||
getNotificationIcon,
|
||||
getNotificationText,
|
||||
getNotificationTarget,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
} from '@/lib/notification-utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Bell, Check, UserPlus, ArrowRightLeft, FileEdit, AlertCircle, MessageSquare, AtSign, Clock } from 'lucide-react';
|
||||
import { Bell, Check } from 'lucide-react';
|
||||
|
||||
interface SystemNotificationsDropdownProps {
|
||||
collapsed?: boolean;
|
||||
@@ -30,100 +35,8 @@ export const SystemNotificationsDropdown = ({ collapsed = false }: SystemNotific
|
||||
.slice(0, 30)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: async (notificationId: number) => {
|
||||
return apiRequest('PUT', `/api/notifications/${notificationId}/read`);
|
||||
},
|
||||
onMutate: async (notificationId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['/api/notifications'] });
|
||||
const previous = queryClient.getQueryData(['/api/notifications']);
|
||||
queryClient.setQueryData(['/api/notifications'], (old: any) => {
|
||||
if (!old?.notifications) return old;
|
||||
return {
|
||||
...old,
|
||||
notifications: old.notifications.map((n: any) =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
),
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, notificationId, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['/api/notifications'], context.previous);
|
||||
}
|
||||
console.error('[SystemNotificationsDropdown] markAsRead error:', err);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const markAllMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiRequest('PUT', '/api/notifications/read-all');
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ['/api/notifications'] });
|
||||
const previous = queryClient.getQueryData(['/api/notifications']);
|
||||
queryClient.setQueryData(['/api/notifications'], (old: any) => {
|
||||
if (!old?.notifications) return old;
|
||||
return {
|
||||
...old,
|
||||
notifications: old.notifications.map((n: any) => ({ ...n, isRead: true })),
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['/api/notifications'], context.previous);
|
||||
}
|
||||
console.error('[SystemNotificationsDropdown] markAllRead error:', err);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
if (CHAT_EVENT_TYPES.includes(type)) {
|
||||
if (type === 'task.mentioned' || type === 'task_mention') {
|
||||
return <AtSign className="w-4 h-4 text-blue-500" />;
|
||||
}
|
||||
return <MessageSquare className="w-4 h-4 text-blue-400" />;
|
||||
}
|
||||
switch (type) {
|
||||
case 'task.assigned':
|
||||
case 'task_assigned':
|
||||
return <UserPlus className="w-4 h-4 text-blue-500" />;
|
||||
case 'task.status.changed':
|
||||
case 'task_status':
|
||||
return <ArrowRightLeft className="w-4 h-4 text-purple-500" />;
|
||||
case 'form.structure.changed':
|
||||
return <FileEdit className="w-4 h-4 text-orange-500" />;
|
||||
case 'task.reminder':
|
||||
return <Clock className="w-4 h-4 text-amber-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationText = (notification: AppNotification): string => {
|
||||
if (CHAT_EVENT_TYPES.includes(notification.type)) {
|
||||
const chatMsg = notification.taskMessage;
|
||||
if (chatMsg?.author) {
|
||||
return `${chatMsg.author.firstName} ${chatMsg.author.middleName || ''} ${chatMsg.author.lastName}`.trim() + `: ${chatMsg.message || ''}`;
|
||||
}
|
||||
return notification.title || notification.task?.title || '';
|
||||
}
|
||||
if (typeof notification.message === 'string' && notification.message) {
|
||||
return notification.message.length > 100
|
||||
? notification.message.substring(0, 100) + '...'
|
||||
: notification.message;
|
||||
}
|
||||
return notification.title || '';
|
||||
};
|
||||
const markAsReadMutation = useMarkNotificationRead();
|
||||
const markAllMutation = useMarkAllNotificationsRead();
|
||||
|
||||
const markAsRead = (notification: AppNotification, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -134,10 +47,11 @@ export const SystemNotificationsDropdown = ({ collapsed = false }: SystemNotific
|
||||
if (!notification.isRead) {
|
||||
markAsReadMutation.mutate(notification.id);
|
||||
}
|
||||
if (notification.task?.id && notification.task?.formId) {
|
||||
const target = getNotificationTarget(notification);
|
||||
if (target) {
|
||||
// SPA navigation so the mark-as-read mutation can complete and the task
|
||||
// detail mounts without a full page reload (which can abort in-flight fetches).
|
||||
setLocation(`/forms/${notification.task.formId}/tasks/${notification.task.id}`);
|
||||
setLocation(target);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
@@ -239,6 +153,22 @@ export const SystemNotificationsDropdown = ({ collapsed = false }: SystemNotific
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setLocation('/notifications');
|
||||
}}
|
||||
data-testid="link-all-notifications"
|
||||
>
|
||||
Все уведомления
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
115
client/src/lib/notification-utils.tsx
Normal file
115
client/src/lib/notification-utils.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { AppNotification, CHAT_EVENT_TYPES } from '@/hooks/useNotifications';
|
||||
import { UserPlus, ArrowRightLeft, FileEdit, AlertCircle, MessageSquare, AtSign, Clock } from 'lucide-react';
|
||||
|
||||
// Общие хелперы уведомлений: используются в SystemNotificationsDropdown и на странице /notifications
|
||||
|
||||
export function getNotificationIcon(type: string) {
|
||||
if (CHAT_EVENT_TYPES.includes(type)) {
|
||||
if (type === 'task.mentioned' || type === 'task_mention') {
|
||||
return <AtSign className="w-4 h-4 text-blue-500" />;
|
||||
}
|
||||
return <MessageSquare className="w-4 h-4 text-blue-400" />;
|
||||
}
|
||||
switch (type) {
|
||||
case 'task.assigned':
|
||||
case 'task_assigned':
|
||||
return <UserPlus className="w-4 h-4 text-blue-500" />;
|
||||
case 'task.status.changed':
|
||||
case 'task_status':
|
||||
return <ArrowRightLeft className="w-4 h-4 text-purple-500" />;
|
||||
case 'form.structure.changed':
|
||||
return <FileEdit className="w-4 h-4 text-orange-500" />;
|
||||
case 'task.reminder':
|
||||
return <Clock className="w-4 h-4 text-amber-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationText(notification: AppNotification): string {
|
||||
if (CHAT_EVENT_TYPES.includes(notification.type)) {
|
||||
const chatMsg = notification.taskMessage;
|
||||
if (chatMsg?.author) {
|
||||
return `${chatMsg.author.firstName} ${chatMsg.author.middleName || ''} ${chatMsg.author.lastName}`.trim() + `: ${chatMsg.message || ''}`;
|
||||
}
|
||||
return notification.title || notification.task?.title || '';
|
||||
}
|
||||
if (typeof notification.message === 'string' && notification.message) {
|
||||
return notification.message.length > 100
|
||||
? notification.message.substring(0, 100) + '...'
|
||||
: notification.message;
|
||||
}
|
||||
return notification.title || '';
|
||||
}
|
||||
|
||||
// Целевой маршрут уведомления (задача), null — если переходить некуда
|
||||
export function getNotificationTarget(notification: AppNotification): string | null {
|
||||
if (notification.task?.id && notification.task?.formId) {
|
||||
return `/forms/${notification.task.formId}/tasks/${notification.task.id}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Отметить одно уведомление прочитанным (с оптимистичным обновлением кэша)
|
||||
export function useMarkNotificationRead() {
|
||||
return useMutation({
|
||||
mutationFn: async (notificationId: number) => {
|
||||
return apiRequest('PUT', `/api/notifications/${notificationId}/read`);
|
||||
},
|
||||
onMutate: async (notificationId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['/api/notifications'] });
|
||||
const previous = queryClient.getQueryData(['/api/notifications']);
|
||||
queryClient.setQueryData(['/api/notifications'], (old: any) => {
|
||||
if (!old?.notifications) return old;
|
||||
return {
|
||||
...old,
|
||||
notifications: old.notifications.map((n: any) =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
),
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _notificationId, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['/api/notifications'], context.previous);
|
||||
}
|
||||
console.error('[notifications] markAsRead error:', err);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Отметить все уведомления прочитанными (с оптимистичным обновлением кэша)
|
||||
export function useMarkAllNotificationsRead() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiRequest('PUT', '/api/notifications/read-all');
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ['/api/notifications'] });
|
||||
const previous = queryClient.getQueryData(['/api/notifications']);
|
||||
queryClient.setQueryData(['/api/notifications'], (old: any) => {
|
||||
if (!old?.notifications) return old;
|
||||
return {
|
||||
...old,
|
||||
notifications: old.notifications.map((n: any) => ({ ...n, isRead: true })),
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['/api/notifications'], context.previous);
|
||||
}
|
||||
console.error('[notifications] markAllRead error:', err);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
200
client/src/pages/NotificationsPage.tsx
Normal file
200
client/src/pages/NotificationsPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation } from 'wouter';
|
||||
import { format } from 'date-fns';
|
||||
import Layout from '@/components/Layout';
|
||||
import { useNotifications, AppNotification } from '@/hooks/useNotifications';
|
||||
import {
|
||||
getNotificationIcon,
|
||||
getNotificationText,
|
||||
getNotificationTarget,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
} from '@/lib/notification-utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bell, Check, CheckCheck, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Сколько уведомлений показываем за раз (кнопка «Показать ещё»)
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
type Filter = 'all' | 'unread';
|
||||
|
||||
/**
|
||||
* Центр уведомлений: полный список уведомлений пользователя.
|
||||
* Dropdown в сайдбаре показывает только непрочитанные — здесь доступна вся история.
|
||||
*/
|
||||
export default function NotificationsPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
const { allNotifications, isLoading } = useNotifications();
|
||||
const markAsReadMutation = useMarkNotificationRead();
|
||||
const markAllMutation = useMarkAllNotificationsRead();
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...allNotifications].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
),
|
||||
[allNotifications]
|
||||
);
|
||||
|
||||
const filtered = filter === 'unread' ? sorted.filter(n => !n.isRead) : sorted;
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const unreadCount = allNotifications.filter(n => !n.isRead).length;
|
||||
|
||||
const handleNotificationClick = (notification: AppNotification) => {
|
||||
if (!notification.isRead) {
|
||||
markAsReadMutation.mutate(notification.id);
|
||||
}
|
||||
const target = getNotificationTarget(notification);
|
||||
if (target) setLocation(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-between pb-2 border-b border-border mb-3">
|
||||
<div>
|
||||
<h1 className="text-sm font-medium">Уведомления</h1>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{unreadCount > 0 ? `Непрочитанных: ${unreadCount}` : 'Все уведомления прочитаны'}
|
||||
</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => markAllMutation.mutate()}
|
||||
disabled={markAllMutation.isPending}
|
||||
data-testid="button-mark-all-read"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
|
||||
Прочитать все
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-3" role="group" aria-label="Фильтр уведомлений">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
aria-pressed={filter === 'all'}
|
||||
onClick={() => { setFilter('all'); setVisibleCount(PAGE_SIZE); }}
|
||||
data-testid="tab-all"
|
||||
>
|
||||
Все ({sorted.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'unread' ? 'default' : 'outline'}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
aria-pressed={filter === 'unread'}
|
||||
onClick={() => { setFilter('unread'); setVisibleCount(PAGE_SIZE); }}
|
||||
data-testid="tab-unread"
|
||||
>
|
||||
Непрочитанные ({unreadCount})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" aria-hidden="true" />
|
||||
<span className="text-xs">Загрузка уведомлений...</span>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Bell className="w-8 h-8 mb-2 opacity-40" aria-hidden="true" />
|
||||
<p className="text-xs">
|
||||
{filter === 'unread' ? 'Нет непрочитанных уведомлений' : 'Уведомлений пока нет'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-border border border-border rounded-lg overflow-hidden">
|
||||
{visible.map((notification) => {
|
||||
const text = getNotificationText(notification);
|
||||
const target = getNotificationTarget(notification);
|
||||
return (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={cn('flex items-stretch', !notification.isRead && 'bg-muted/50')}
|
||||
>
|
||||
{/* Кликабельная зона — настоящая кнопка (клавиатура/скринридер),
|
||||
кнопка «Прочитано» лежит рядом, а не внутри (nested-interactive) */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 text-left px-3 py-3 transition-colors',
|
||||
target
|
||||
? 'cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none'
|
||||
: 'cursor-default'
|
||||
)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
data-testid={`notification-${notification.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1" aria-hidden="true">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{!notification.isRead && (
|
||||
<>
|
||||
<span className="w-2 h-2 rounded-full bg-primary flex-shrink-0" aria-hidden="true" />
|
||||
<span className="sr-only">Непрочитано:</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{notification.title || notification.task?.title || 'Уведомление'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{text && (
|
||||
<span className="block text-xs text-muted-foreground line-clamp-2 mb-2">{text}</span>
|
||||
)}
|
||||
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{format(new Date(notification.createdAt), 'dd.MM.yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!notification.isRead && (
|
||||
<div className="flex items-end px-2 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => markAsReadMutation.mutate(notification.id)}
|
||||
aria-label="Отметить прочитанным"
|
||||
data-testid={`mark-read-${notification.id}`}
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
Прочитано
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{visibleCount < filtered.length && (
|
||||
<div className="flex justify-center mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-4 text-xs"
|
||||
onClick={() => setVisibleCount(c => c + PAGE_SIZE)}
|
||||
data-testid="button-load-more"
|
||||
>
|
||||
Показать ещё ({filtered.length - visibleCount})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user