All files / components/layout NotificationBell.tsx

77.77% Statements 14/18
87.5% Branches 7/8
62.5% Functions 5/8
75% Lines 12/16

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129                                                                                              5x 5x 5x   11x 6x   5x 2x     5x       5x         5x                                                                           6x 6x                                            
// src/components/layout/NotificationBell.tsx
import {type ElementType, type ReactElement, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {
	Badge,
	Box,
	Divider,
	IconButton,
	List,
	ListItemButton,
	ListItemIcon,
	ListItemText,
	Menu,
	Tooltip,
	Typography
} from '@mui/material';
import {Notifications as NotificationsIcon, NotificationsNone as NotificationsNoneIcon} from '@mui/icons-material';
 
// ============================================================================
// Types
// ============================================================================
 
export interface NotificationItem {
	/** Stable key + display label, e.g. "Loan requests". */
	label: string;
	/** Count of pending items; rows with count <= 0 are not rendered. */
	count: number;
	/** Route to navigate to when the row is clicked. */
	to: string;
	icon: ElementType;
}
 
interface NotificationBellProps {
	items: NotificationItem[];
}
 
// ============================================================================
// NotificationBell Component
// ============================================================================
 
/**
 * Header notification bell (FSC-111). Surfaces pending actions (loan requests,
 * disbursements, document reviews, etc.) as a badge + dropdown. The items are
 * supplied by the layout, which already runs the underlying queries for the
 * nav badges, so the bell adds no extra network traffic.
 */
export default function NotificationBell({items}: NotificationBellProps): ReactElement {
	const navigate = useNavigate();
	const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
	const open = anchorEl !== null;
 
	const pending = items.filter((item) => item.count > 0);
	const total = pending.reduce((sum, item) => sum + item.count, 0);
 
	const handleOpen = (event: React.MouseEvent<HTMLElement>): void => {
		setAnchorEl(event.currentTarget);
	};
 
	const handleClose = (): void => {
		setAnchorEl(null);
	};
 
	const handleSelect = (to: string): void => {
		setAnchorEl(null);
		navigate(to);
	};
 
	return (
		<>
			<Tooltip title={total > 0 ? `${total} pending action${total === 1 ? '' : 's'}` : 'No pending actions'}>
				<IconButton
					color="inherit"
					aria-label={`notifications: ${total} pending`}
					onClick={handleOpen}
				>
					<Badge badgeContent={total} color="error" overlap="circular">
						{total > 0 ? <NotificationsIcon/> : <NotificationsNoneIcon/>}
					</Badge>
				</IconButton>
			</Tooltip>
 
			<Menu
				anchorEl={anchorEl}
				open={open}
				onClose={handleClose}
				anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
				transformOrigin={{vertical: 'top', horizontal: 'right'}}
				slotProps={{paper: {sx: {width: 320, maxWidth: '100%', mt: 1}}}}
			>
				<Box sx={{px: 2, py: 1.5}}>
					<Typography variant="subtitle2" sx={{fontWeight: 600}}>
						Notifications
					</Typography>
				</Box>
				<Divider/>
 
				{pending.length === 0 ? (
					<Box sx={{px: 2, py: 3, textAlign: 'center'}}>
						<Typography variant="body2" sx={{color: 'text.secondary'}}>
							You&apos;re all caught up.
						</Typography>
					</Box>
				) : (
					<List disablePadding>
						{pending.map((item) => {
							const Icon = item.icon;
							return (
								<ListItemButton key={item.label} onClick={(): void => {
									handleSelect(item.to);
								}}>
									<ListItemIcon sx={{minWidth: 40}}>
										<Badge badgeContent={item.count} color="error">
											<Icon fontSize="small"/>
										</Badge>
									</ListItemIcon>
									<ListItemText
										primary={item.label}
										slotProps={{primary: {variant: 'body2', sx: {fontWeight: 500}}}}
									/>
								</ListItemButton>
							);
						})}
					</List>
				)}
			</Menu>
		</>
	);
}