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:
2026-09-08 09:32:36 +03:00
parent 6e7270d2ee
commit 42e5ace655
12 changed files with 1249 additions and 127 deletions

View File

@@ -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} />

View File

@@ -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" />

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);

View 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'] });
},
});
}

View 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>
);
}