feat: Scheduled Maintenance feature complete
This commit is contained in:
@@ -3,7 +3,13 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { NotificationSummary, DueMaintenanceItem, ExpiringDocument } from '../types/notifications.types';
|
||||
import {
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
UserNotification,
|
||||
UnreadNotificationCount
|
||||
} from '../types/notifications.types';
|
||||
|
||||
export const notificationsApi = {
|
||||
getSummary: async (): Promise<NotificationSummary> => {
|
||||
@@ -20,4 +26,31 @@ export const notificationsApi = {
|
||||
const response = await apiClient.get('/notifications/documents');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// In-App Notifications
|
||||
getInAppNotifications: async (limit = 20, includeRead = false): Promise<UserNotification[]> => {
|
||||
const response = await apiClient.get('/notifications/in-app', {
|
||||
params: { limit, includeRead: includeRead.toString() }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUnreadCount: async (): Promise<UnreadNotificationCount> => {
|
||||
const response = await apiClient.get('/notifications/in-app/count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsRead: async (id: string): Promise<UserNotification> => {
|
||||
const response = await apiClient.put(`/notifications/in-app/${id}/read`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAllAsRead: async (): Promise<{ markedAsRead: number }> => {
|
||||
const response = await apiClient.put('/notifications/in-app/read-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteNotification: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/notifications/in-app/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @ai-summary Bell icon with dropdown for in-app notifications
|
||||
* @ai-context Displays in header with unread count badge
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Badge,
|
||||
Popover,
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Button,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { useInAppNotifications } from '../hooks/useInAppNotifications';
|
||||
|
||||
// Helper function for relative time
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export const NotificationBell: React.FC = () => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
} = useInAppNotifications();
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
await markAsRead(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllAsRead();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteNotification(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
}}
|
||||
aria-label="notifications"
|
||||
>
|
||||
<Badge badgeContent={unreadCount} color="error" max={99}>
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100vw', sm: 360 },
|
||||
maxWidth: 360,
|
||||
maxHeight: 480
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>Notifications</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Button size="small" onClick={handleMarkAllAsRead}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : notifications.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">No notifications</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ py: 0, maxHeight: 360, overflow: 'auto' }}>
|
||||
{notifications.map((notification) => (
|
||||
<ListItem
|
||||
key={notification.id}
|
||||
sx={{
|
||||
bgcolor: notification.isRead ? 'transparent' : 'action.hover',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||
{!notification.isRead && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
title="Mark as read"
|
||||
sx={{ minWidth: 36, minHeight: 36 }}
|
||||
>
|
||||
<CheckIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
title="Delete"
|
||||
sx={{ minWidth: 36, minHeight: 36 }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{ pr: 8 }}
|
||||
primary={
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: notification.isRead ? 400 : 600,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{notification.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{notification.message}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @ai-summary Hook for in-app notifications with real-time count
|
||||
* @ai-context Provides notification list and unread count with polling
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { notificationsApi } from '../api/notifications.api';
|
||||
import type { UserNotification, UnreadNotificationCount } from '../types/notifications.types';
|
||||
|
||||
export function useInAppNotifications() {
|
||||
const { isAuthenticated } = useAuth0();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Unread count - polls every 60 seconds
|
||||
const countQuery = useQuery<UnreadNotificationCount>({
|
||||
queryKey: ['notificationCount'],
|
||||
queryFn: notificationsApi.getUnreadCount,
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60 * 1000, // Poll every minute
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
// Notification list
|
||||
const listQuery = useQuery<UserNotification[]>({
|
||||
queryKey: ['inAppNotifications'],
|
||||
queryFn: () => notificationsApi.getInAppNotifications(20, false),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Mark as read mutation
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: notificationsApi.markAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all as read mutation
|
||||
const markAllAsReadMutation = useMutation({
|
||||
mutationFn: notificationsApi.markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: notificationsApi.deleteNotification,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifications: listQuery.data ?? [],
|
||||
unreadCount: countQuery.data?.total ?? 0,
|
||||
countByType: countQuery.data,
|
||||
isLoading: listQuery.isLoading || countQuery.isLoading,
|
||||
isError: listQuery.isError || countQuery.isError,
|
||||
markAsRead: markAsReadMutation.mutateAsync,
|
||||
markAllAsRead: markAllAsReadMutation.mutateAsync,
|
||||
deleteNotification: deleteMutation.mutateAsync,
|
||||
refetch: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,4 +5,6 @@
|
||||
export * from './api/notifications.api';
|
||||
export * from './types/notifications.types';
|
||||
export * from './hooks/useLoginNotifications';
|
||||
export * from './hooks/useInAppNotifications';
|
||||
export * from './components/EmailNotificationToggle';
|
||||
export * from './components/NotificationBell';
|
||||
|
||||
@@ -34,3 +34,22 @@ export interface ExpiringDocument {
|
||||
isExpired: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
notificationType: string;
|
||||
title: string;
|
||||
message: string;
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
vehicleId?: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
readAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UnreadNotificationCount {
|
||||
total: number;
|
||||
maintenance: number;
|
||||
documents: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user