feat(reminders): получатели в отложенных/входящих, приватные сообщения чата, календарь, бейдж уведомлений
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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: [] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user