feat(reminders): получатели в отложенных/входящих, приватные сообщения чата, календарь, бейдж уведомлений

This commit is contained in:
2026-09-21 10:26:53 +03:00
parent 17e6ebf412
commit aa316141be
20 changed files with 312 additions and 36 deletions

View File

@@ -114,7 +114,7 @@ export function MobileBottomNav() {
const { data: foldersData } = useBookmarkFolders();
const { data: rootBookmarksData } = useBookmarksByFolder(null);
const { messengerUnread: unreadCount, inboxCount } = useUnreadBadges();
const { messengerUnread: unreadCount, inboxCount, notificationsUnread } = useUnreadBadges();
// Прячем GPS, если у пользователя нет доступа к модулю
const { data: gpsConfig } = useQuery<{ success: boolean; allowed: boolean }>({
@@ -324,8 +324,13 @@ export function MobileBottomNav() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Меню" className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:text-foreground transition-colors">
<button aria-label="Меню" className="relative flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:text-foreground transition-colors">
<Menu className="w-5 h-5" aria-hidden="true" />
{notificationsUnread > 0 && (
<span aria-hidden="true" className="absolute top-1 right-1 bg-destructive text-destructive-foreground text-[10px] font-bold rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-0.5 leading-none">
{notificationsUnread > 99 ? '99+' : notificationsUnread}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-52 mb-2">
@@ -333,6 +338,11 @@ export function MobileBottomNav() {
<DropdownMenuItem className="text-xs">
<Bell className="w-3.5 h-3.5 mr-2" />
Уведомления
{notificationsUnread > 0 && (
<span className="ml-auto bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1">
{notificationsUnread > 99 ? '99+' : notificationsUnread}
</span>
)}
</DropdownMenuItem>
</Link>
<Link href="/users">

View File

@@ -55,7 +55,7 @@ interface TaskMessage {
taskId: number;
replyToMessageId?: number;
message: string;
messageType: 'comment' | 'system' | 'status_change' | 'bot';
messageType: 'comment' | 'system' | 'status_change' | 'bot' | 'reminder';
createdAt: string;
mentionedUserIds?: number[];
botId?: number;
@@ -1418,7 +1418,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// read receipts, inline-редактирование, скролл к ответу с учётом виртуализации)
const renderMessage = (message: TaskChatMessage) => {
const isMyMessage = !!(user && message.author && message.author.id === user.id);
const isSystemLike = message.messageType === 'system' || message.messageType === 'status_change';
const isSystemLike = message.messageType === 'system' || message.messageType === 'status_change' || message.messageType === 'reminder';
return (
<div key={message.id} className="py-px" data-message-id={message.id}>
<MessageBubble<TaskChatMessage>
@@ -1437,7 +1437,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
onReplyClick={scrollToMessage}
authorBadge={!isMyMessage && isSystemLike ? (
<Badge variant="secondary" className="text-xs ml-1 inline">
{message.messageType === 'system' ? 'Система' : 'Статус'}
{message.messageType === 'system' ? 'Система' : message.messageType === 'reminder' ? 'Напоминание' : 'Статус'}
</Badge>
) : undefined}
renderContent={renderMessageContent}

View File

@@ -1,4 +1,5 @@
import { useDraggable } from '@dnd-kit/core';
import { Bell } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatOrg } from '@/lib/timezone';
import type { CalendarTask } from '@/hooks/useCalendarTasks';
@@ -12,8 +13,10 @@ interface CalendarTaskCardProps {
export function CalendarTaskCard({ task, onClick, compact, showTime }: CalendarTaskCardProps) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `calendar-task-${task.id}`,
id: `calendar-task-${task.id}${task.isReminder ? '-reminder' : ''}`,
data: { task },
// События-напоминания не переносятся drag&drop (менять remindAt перетаскиванием нельзя)
disabled: !!task.isReminder,
});
return (
@@ -30,19 +33,21 @@ export function CalendarTaskCard({ task, onClick, compact, showTime }: CalendarT
"w-full text-left rounded px-1.5 py-1 text-xs leading-tight shadow-sm border",
"hover:brightness-95 active:scale-[0.98] transition-transform",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isDragging && "opacity-50 cursor-grabbing z-50"
isDragging && "opacity-50 cursor-grabbing z-50",
task.isReminder && "border-dashed"
)}
style={{
backgroundColor: task.statusColor || '#e5e7eb',
borderColor: 'rgba(0,0,0,0.08)',
color: '#0f172a',
}}
title={task.title}
title={task.isReminder ? `Напоминание: ${task.title}` : task.title}
>
<span className={cn("block truncate font-medium", compact && "text-[10px]")}>
{showTime && task.dueDate && (
<span className="opacity-80 mr-0.5">{formatOrg(task.dueDate, 'HH:mm')}</span>
)}
{task.isReminder && <Bell className="w-3 h-3 inline-block mr-0.5 -mt-0.5" />}
{task.title}
</span>
{!compact && task.statusName && (

View File

@@ -12,11 +12,24 @@ export interface CalendarTask {
statusName: string | null;
isCompleted: boolean;
isAllDay: boolean;
// Событие-напоминание (по дате remindAt): не draggable, клик открывает задачу
isReminder?: boolean;
}
// Напоминание на задаче, где текущий пользователь — автор или получатель
export interface CalendarReminder {
taskId: number;
title: string;
remindAt: string;
formId: number;
statusName: string | null;
statusColor: string | null;
}
interface CalendarTasksResponse {
success: boolean;
tasks: CalendarTask[];
reminders: CalendarReminder[];
}
function buildCalendarQueryKey(from: Date, to: Date, formIds: number[]) {
@@ -37,7 +50,7 @@ export function useCalendarTasks(from: Date, to: Date, formIds: number[]) {
return res.json();
},
enabled: formIds.length > 0 && !isNaN(from.getTime()) && !isNaN(to.getTime()),
placeholderData: { success: true, tasks: [] },
placeholderData: { success: true, tasks: [], reminders: [] },
});
}

View File

@@ -40,6 +40,7 @@ function ensureBadgeSseHandlers() {
const invalidateInboxCount = () => {
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
queryClient.invalidateQueries({ queryKey: ['/api/notifications/unread-count'] });
};
sseManager.on('notification', invalidateInboxCount);
sseManager.on('task_updated', invalidateInboxCount);
@@ -69,9 +70,16 @@ export function useUnreadBadges() {
staleTime: 10000,
});
const { data: notificationsData } = useQuery<{ count: number }>({
queryKey: ['/api/notifications/unread-count'],
refetchInterval: isConnected ? false : 30000,
staleTime: 10000,
});
return {
messengerUnread: messengerData?.unreadCount ?? 0,
inboxCount: inboxData?.count ?? 0,
notificationsUnread: notificationsData?.count ?? 0,
isConnected,
};
}

View File

@@ -80,6 +80,7 @@ export function useMarkNotificationRead() {
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
queryClient.invalidateQueries({ queryKey: ['/api/notifications/unread-count'] });
},
});
}
@@ -110,6 +111,7 @@ export function useMarkAllNotificationsRead() {
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
queryClient.invalidateQueries({ queryKey: ['/api/notifications/unread-count'] });
},
});
}

View File

@@ -93,7 +93,23 @@ export default function CalendarPage() {
}, [currentDate, view]);
const { data: tasksData, isLoading } = useCalendarTasks(from, to, selectedFormIds);
const tasks = tasksData?.tasks || [];
// Напоминания подмешиваются как обычные события (флаг isReminder — без drag&drop)
const tasks = useMemo<CalendarTask[]>(() => {
const base = tasksData?.tasks || [];
const reminderTasks: CalendarTask[] = (tasksData?.reminders || []).map((r) => ({
id: r.taskId,
title: r.title,
dueDate: r.remindAt,
formId: r.formId,
currentStatusId: 0,
statusColor: r.statusColor,
statusName: r.statusName,
isCompleted: false,
isAllDay: false,
isReminder: true,
}));
return [...base, ...reminderTasks];
}, [tasksData]);
const updateDueDate = useUpdateCalendarTaskDueDate();

View File

@@ -102,6 +102,7 @@ interface HomeTask {
unreadNotifications?: number;
lastInteractionAt?: string;
remindAt?: string;
createdByName?: string;
hiddenAt?: string;
fieldValues?: Record<string, unknown>;
}
@@ -409,7 +410,10 @@ function TaskTableRow({
<Eye className="w-3 h-3" />Вернуть
</button>
) : showRemindAt && task.remindAt ? (
<span className="text-[11px] text-warning-foreground font-medium">{formatRemindAt(task.remindAt)}</span>
<span className="text-[11px] text-warning-foreground font-medium">
{formatRemindAt(task.remindAt)}
{task.createdByName ? <span className="text-muted-foreground font-normal"> · от {task.createdByName}</span> : null}
</span>
) : (
<span className="text-[11px] text-muted-foreground">{formatRelativeDate(dateStr)}</span>
)}