feat: Scheduled Maintenance feature complete

This commit is contained in:
Eric Gullickson
2025-12-22 14:12:33 -06:00
parent c017b8816f
commit 91b4534e76
44 changed files with 2740 additions and 117 deletions

View File

@@ -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}`);
},
};

View File

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

View File

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

View File

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

View File

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