- Add NotificationDropdown component with filtering (all/pending/unread) - Create NotificationItem component with accept/reject/assign actions - Implement notification service with mock data for vouches and praise - Add data types for Notification, Vouch, Praise, and RCard categories - Include default rCard categories (Business, Colleague, Family, Friend, etc.) - Add rCard assignment dialog for organizing accepted vouches/praise - Replace static notification badge with dynamic notification system - Support real-time notification management with state updates Features: - Accept/reject vouches and praise from other users - Assign accepted items to specific rCard categories - Mark notifications as read/unread - Filter by status (pending, unread, all) - Real-time badge count updates - Professional notification UI with avatars and status chips 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>main
parent
f63a00a9f7
commit
142920b264
@ -0,0 +1,247 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import { |
||||||
|
IconButton, |
||||||
|
Badge, |
||||||
|
Menu, |
||||||
|
Box, |
||||||
|
Typography, |
||||||
|
List, |
||||||
|
Divider, |
||||||
|
Button, |
||||||
|
Chip, |
||||||
|
} from '@mui/material'; |
||||||
|
import { |
||||||
|
NotificationsNone, |
||||||
|
Notifications, |
||||||
|
MarkEmailRead, |
||||||
|
} from '@mui/icons-material'; |
||||||
|
import type { Notification, NotificationSummary } from '../../types/notification'; |
||||||
|
import NotificationItem from './NotificationItem'; |
||||||
|
|
||||||
|
interface NotificationDropdownProps { |
||||||
|
notifications: Notification[]; |
||||||
|
summary: NotificationSummary; |
||||||
|
onMarkAsRead: (notificationId: string) => void; |
||||||
|
onMarkAllAsRead: () => void; |
||||||
|
onAcceptVouch: (notificationId: string, vouchId: string) => void; |
||||||
|
onRejectVouch: (notificationId: string, vouchId: string) => void; |
||||||
|
onAcceptPraise: (notificationId: string, praiseId: string) => void; |
||||||
|
onRejectPraise: (notificationId: string, praiseId: string) => void; |
||||||
|
onAssignToRCard: (notificationId: string, rCardId: string) => void; |
||||||
|
} |
||||||
|
|
||||||
|
const NotificationDropdown = ({ |
||||||
|
notifications, |
||||||
|
summary, |
||||||
|
onMarkAsRead, |
||||||
|
onMarkAllAsRead, |
||||||
|
onAcceptVouch, |
||||||
|
onRejectVouch, |
||||||
|
onAcceptPraise, |
||||||
|
onRejectPraise, |
||||||
|
onAssignToRCard, |
||||||
|
}: NotificationDropdownProps) => { |
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); |
||||||
|
const [filter, setFilter] = useState<'all' | 'pending' | 'unread'>('all'); |
||||||
|
|
||||||
|
const isOpen = Boolean(anchorEl); |
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => { |
||||||
|
setAnchorEl(event.currentTarget); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleClose = () => { |
||||||
|
setAnchorEl(null); |
||||||
|
}; |
||||||
|
|
||||||
|
const filteredNotifications = notifications.filter(notification => { |
||||||
|
switch (filter) { |
||||||
|
case 'pending': |
||||||
|
return notification.status === 'pending' && notification.isActionable; |
||||||
|
case 'unread': |
||||||
|
return !notification.isRead; |
||||||
|
default: |
||||||
|
return true; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const getFilterChipColor = (filterType: string) => { |
||||||
|
return filter === filterType ? 'primary' : 'default'; |
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<IconButton |
||||||
|
size="large" |
||||||
|
color="inherit" |
||||||
|
onClick={handleClick} |
||||||
|
aria-label="notifications" |
||||||
|
aria-expanded={isOpen ? 'true' : undefined} |
||||||
|
aria-haspopup="true" |
||||||
|
> |
||||||
|
<Badge badgeContent={summary.unread} color="error"> |
||||||
|
{summary.unread > 0 ? <Notifications /> : <NotificationsNone />} |
||||||
|
</Badge> |
||||||
|
</IconButton> |
||||||
|
|
||||||
|
<Menu |
||||||
|
anchorEl={anchorEl} |
||||||
|
open={isOpen} |
||||||
|
onClose={handleClose} |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
PaperProps={{ |
||||||
|
elevation: 8, |
||||||
|
sx: { |
||||||
|
width: 400, |
||||||
|
maxWidth: '90vw', |
||||||
|
maxHeight: '80vh', |
||||||
|
mt: 1.5, |
||||||
|
borderRadius: 2, |
||||||
|
border: 1, |
||||||
|
borderColor: 'divider', |
||||||
|
'&::before': { |
||||||
|
content: '""', |
||||||
|
display: 'block', |
||||||
|
position: 'absolute', |
||||||
|
top: 0, |
||||||
|
right: 20, |
||||||
|
width: 10, |
||||||
|
height: 10, |
||||||
|
bgcolor: 'background.paper', |
||||||
|
transform: 'translateY(-50%) rotate(45deg)', |
||||||
|
zIndex: 0, |
||||||
|
border: 1, |
||||||
|
borderColor: 'divider', |
||||||
|
borderBottom: 0, |
||||||
|
borderRight: 0, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}} |
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }} |
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} |
||||||
|
> |
||||||
|
{/* Header */} |
||||||
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}> |
||||||
|
Notifications |
||||||
|
</Typography> |
||||||
|
{summary.unread > 0 && ( |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
startIcon={<MarkEmailRead />} |
||||||
|
onClick={onMarkAllAsRead} |
||||||
|
sx={{ textTransform: 'none' }} |
||||||
|
> |
||||||
|
Mark all read |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Summary Stats */} |
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}> |
||||||
|
<Chip |
||||||
|
size="small" |
||||||
|
label={`${summary.total} Total`} |
||||||
|
sx={{ fontSize: '0.75rem' }} |
||||||
|
/> |
||||||
|
{summary.unread > 0 && ( |
||||||
|
<Chip |
||||||
|
size="small" |
||||||
|
label={`${summary.unread} Unread`} |
||||||
|
color="error" |
||||||
|
sx={{ fontSize: '0.75rem' }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{summary.pending > 0 && ( |
||||||
|
<Chip |
||||||
|
size="small" |
||||||
|
label={`${summary.pending} Pending`} |
||||||
|
color="warning" |
||||||
|
sx={{ fontSize: '0.75rem' }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Filter Chips */} |
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}> |
||||||
|
<Chip |
||||||
|
size="small" |
||||||
|
label="All" |
||||||
|
onClick={() => setFilter('all')} |
||||||
|
color={getFilterChipColor('all')} |
||||||
|
variant={filter === 'all' ? 'filled' : 'outlined'} |
||||||
|
sx={{ fontSize: '0.75rem', cursor: 'pointer' }} |
||||||
|
/> |
||||||
|
<Chip |
||||||
|
size="small" |
||||||
|
label="Pending" |
||||||
|
onClick={() => setFilter('pending')} |
||||||
|
color={getFilterChipColor('pending')} |
||||||
|
variant={filter === 'pending' ? 'filled' : 'outlined'} |
||||||
|
sx={{ fontSize: '0.75rem', cursor: 'pointer' }} |
||||||
|
/> |
||||||
|
<Chip |
||||||
|
size="small" |
||||||
|
label="Unread" |
||||||
|
onClick={() => setFilter('unread')} |
||||||
|
color={getFilterChipColor('unread')} |
||||||
|
variant={filter === 'unread' ? 'filled' : 'outlined'} |
||||||
|
sx={{ fontSize: '0.75rem', cursor: 'pointer' }} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Notification List */} |
||||||
|
<Box sx={{ maxHeight: 400, overflow: 'auto' }}> |
||||||
|
{filteredNotifications.length === 0 ? ( |
||||||
|
<Box sx={{ p: 4, textAlign: 'center' }}> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
{filter === 'all'
|
||||||
|
? 'No notifications yet' |
||||||
|
: filter === 'pending' |
||||||
|
? 'No pending notifications' |
||||||
|
: 'No unread notifications' |
||||||
|
} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
) : ( |
||||||
|
<List sx={{ p: 0 }}> |
||||||
|
{filteredNotifications.map((notification, index) => ( |
||||||
|
<Box key={notification.id}> |
||||||
|
<NotificationItem |
||||||
|
notification={notification} |
||||||
|
onMarkAsRead={onMarkAsRead} |
||||||
|
onAcceptVouch={onAcceptVouch} |
||||||
|
onRejectVouch={onRejectVouch} |
||||||
|
onAcceptPraise={onAcceptPraise} |
||||||
|
onRejectPraise={onRejectPraise} |
||||||
|
onAssignToRCard={onAssignToRCard} |
||||||
|
/> |
||||||
|
{index < filteredNotifications.length - 1 && <Divider />} |
||||||
|
</Box> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Footer */} |
||||||
|
{summary.total > 0 && ( |
||||||
|
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', textAlign: 'center' }}> |
||||||
|
<Button |
||||||
|
variant="text" |
||||||
|
size="small" |
||||||
|
onClick={handleClose} |
||||||
|
sx={{ textTransform: 'none' }} |
||||||
|
> |
||||||
|
View All Notifications |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Menu> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default NotificationDropdown; |
@ -0,0 +1,369 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import { |
||||||
|
ListItem, |
||||||
|
Box, |
||||||
|
Avatar, |
||||||
|
Typography, |
||||||
|
Button, |
||||||
|
Chip, |
||||||
|
IconButton, |
||||||
|
Menu, |
||||||
|
MenuItem, |
||||||
|
Dialog, |
||||||
|
DialogTitle, |
||||||
|
DialogContent, |
||||||
|
DialogActions, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
Select, |
||||||
|
useTheme, |
||||||
|
alpha, |
||||||
|
} from '@mui/material'; |
||||||
|
import { |
||||||
|
ThumbUp, |
||||||
|
StarBorder, |
||||||
|
CheckCircle, |
||||||
|
Cancel, |
||||||
|
Assignment, |
||||||
|
MoreVert, |
||||||
|
Business, |
||||||
|
PersonOutline, |
||||||
|
Groups, |
||||||
|
FamilyRestroom, |
||||||
|
Favorite, |
||||||
|
Home, |
||||||
|
} from '@mui/icons-material'; |
||||||
|
import type { Notification } from '../../types/notification'; |
||||||
|
import { DEFAULT_RCARDS } from '../../types/notification'; |
||||||
|
|
||||||
|
interface NotificationItemProps { |
||||||
|
notification: Notification; |
||||||
|
onMarkAsRead: (notificationId: string) => void; |
||||||
|
onAcceptVouch: (notificationId: string, vouchId: string) => void; |
||||||
|
onRejectVouch: (notificationId: string, vouchId: string) => void; |
||||||
|
onAcceptPraise: (notificationId: string, praiseId: string) => void; |
||||||
|
onRejectPraise: (notificationId: string, praiseId: string) => void; |
||||||
|
onAssignToRCard: (notificationId: string, rCardId: string) => void; |
||||||
|
} |
||||||
|
|
||||||
|
const NotificationItem = ({ |
||||||
|
notification, |
||||||
|
onMarkAsRead, |
||||||
|
onAcceptVouch, |
||||||
|
onRejectVouch, |
||||||
|
onAcceptPraise, |
||||||
|
onRejectPraise, |
||||||
|
onAssignToRCard, |
||||||
|
}: NotificationItemProps) => { |
||||||
|
const theme = useTheme(); |
||||||
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null); |
||||||
|
const [showAssignDialog, setShowAssignDialog] = useState(false); |
||||||
|
const [selectedRCard, setSelectedRCard] = useState(''); |
||||||
|
|
||||||
|
const isMenuOpen = Boolean(menuAnchor); |
||||||
|
|
||||||
|
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => { |
||||||
|
event.stopPropagation(); |
||||||
|
setMenuAnchor(event.currentTarget); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleMenuClose = () => { |
||||||
|
setMenuAnchor(null); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleAccept = () => { |
||||||
|
if (notification.type === 'vouch' && notification.metadata?.vouchId) { |
||||||
|
onAcceptVouch(notification.id, notification.metadata.vouchId); |
||||||
|
} else if (notification.type === 'praise' && notification.metadata?.praiseId) { |
||||||
|
onAcceptPraise(notification.id, notification.metadata.praiseId); |
||||||
|
} |
||||||
|
handleMenuClose(); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleReject = () => { |
||||||
|
if (notification.type === 'vouch' && notification.metadata?.vouchId) { |
||||||
|
onRejectVouch(notification.id, notification.metadata.vouchId); |
||||||
|
} else if (notification.type === 'praise' && notification.metadata?.praiseId) { |
||||||
|
onRejectPraise(notification.id, notification.metadata.praiseId); |
||||||
|
} |
||||||
|
handleMenuClose(); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleAssignClick = () => { |
||||||
|
setShowAssignDialog(true); |
||||||
|
handleMenuClose(); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleAssignSubmit = () => { |
||||||
|
if (selectedRCard) { |
||||||
|
onAssignToRCard(notification.id, selectedRCard); |
||||||
|
setShowAssignDialog(false); |
||||||
|
setSelectedRCard(''); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const handleMarkAsRead = () => { |
||||||
|
onMarkAsRead(notification.id); |
||||||
|
handleMenuClose(); |
||||||
|
}; |
||||||
|
|
||||||
|
const getNotificationIcon = () => { |
||||||
|
switch (notification.type) { |
||||||
|
case 'vouch': |
||||||
|
return <ThumbUp sx={{ color: 'primary.main' }} />; |
||||||
|
case 'praise': |
||||||
|
return <StarBorder sx={{ color: 'warning.main' }} />; |
||||||
|
default: |
||||||
|
return null; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const getStatusChip = () => { |
||||||
|
switch (notification.status) { |
||||||
|
case 'pending': |
||||||
|
return <Chip label="Pending" size="small" color="warning" />; |
||||||
|
case 'accepted': |
||||||
|
return <Chip label="Accepted" size="small" color="success" />; |
||||||
|
case 'rejected': |
||||||
|
return <Chip label="Declined" size="small" color="error" />; |
||||||
|
case 'completed': |
||||||
|
return <Chip label="Assigned" size="small" color="info" />; |
||||||
|
default: |
||||||
|
return null; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const getRCardIcon = (iconName: string) => { |
||||||
|
switch (iconName) { |
||||||
|
case 'Business': |
||||||
|
return <Business />; |
||||||
|
case 'PersonOutline': |
||||||
|
return <PersonOutline />; |
||||||
|
case 'Groups': |
||||||
|
return <Groups />; |
||||||
|
case 'FamilyRestroom': |
||||||
|
return <FamilyRestroom />; |
||||||
|
case 'Favorite': |
||||||
|
return <Favorite />; |
||||||
|
case 'Home': |
||||||
|
return <Home />; |
||||||
|
default: |
||||||
|
return <PersonOutline />; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const formatTimeAgo = (date: Date) => { |
||||||
|
const now = new Date(); |
||||||
|
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60)); |
||||||
|
|
||||||
|
if (diffInMinutes < 1) return 'Just now'; |
||||||
|
if (diffInMinutes < 60) return `${diffInMinutes}m ago`; |
||||||
|
|
||||||
|
const diffInHours = Math.floor(diffInMinutes / 60); |
||||||
|
if (diffInHours < 24) return `${diffInHours}h ago`; |
||||||
|
|
||||||
|
const diffInDays = Math.floor(diffInHours / 24); |
||||||
|
if (diffInDays < 7) return `${diffInDays}d ago`; |
||||||
|
|
||||||
|
return date.toLocaleDateString(); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<ListItem |
||||||
|
sx={{ |
||||||
|
p: 2, |
||||||
|
borderLeft: 4, |
||||||
|
borderLeftColor: notification.isRead ? 'transparent' : 'primary.main', |
||||||
|
backgroundColor: notification.isRead
|
||||||
|
? 'transparent'
|
||||||
|
: alpha(theme.palette.primary.main, 0.02), |
||||||
|
'&:hover': { |
||||||
|
backgroundColor: alpha(theme.palette.action.hover, 0.5), |
||||||
|
}, |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box sx={{ display: 'flex', width: '100%', gap: 2 }}> |
||||||
|
{/* Avatar and Icon */} |
||||||
|
<Box sx={{ position: 'relative' }}> |
||||||
|
<Avatar |
||||||
|
src={notification.fromUserAvatar} |
||||||
|
alt={notification.fromUserName} |
||||||
|
sx={{ width: 48, height: 48 }} |
||||||
|
> |
||||||
|
{notification.fromUserName?.charAt(0) || 'N'} |
||||||
|
</Avatar> |
||||||
|
{getNotificationIcon() && ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
bottom: -4, |
||||||
|
right: -4, |
||||||
|
backgroundColor: 'background.paper', |
||||||
|
borderRadius: '50%', |
||||||
|
p: 0.5, |
||||||
|
border: 2, |
||||||
|
borderColor: 'background.paper', |
||||||
|
}} |
||||||
|
> |
||||||
|
{getNotificationIcon()} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Content */} |
||||||
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}> |
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, lineHeight: 1.2 }}> |
||||||
|
{notification.title} |
||||||
|
</Typography> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
{getStatusChip()} |
||||||
|
<IconButton size="small" onClick={handleMenuClick}> |
||||||
|
<MoreVert /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Typography |
||||||
|
variant="body2" |
||||||
|
color="text.secondary" |
||||||
|
sx={{
|
||||||
|
mb: 1, |
||||||
|
display: '-webkit-box', |
||||||
|
WebkitLineClamp: 2, |
||||||
|
WebkitBoxOrient: 'vertical', |
||||||
|
overflow: 'hidden', |
||||||
|
}} |
||||||
|
> |
||||||
|
{notification.message} |
||||||
|
</Typography> |
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{formatTimeAgo(notification.createdAt)} |
||||||
|
</Typography> |
||||||
|
|
||||||
|
{/* Action Buttons */} |
||||||
|
{notification.isActionable && notification.status === 'pending' && ( |
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
startIcon={<Cancel />} |
||||||
|
onClick={handleReject} |
||||||
|
sx={{ textTransform: 'none', minWidth: 'auto' }} |
||||||
|
> |
||||||
|
Decline |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
startIcon={<CheckCircle />} |
||||||
|
onClick={handleAccept} |
||||||
|
sx={{ textTransform: 'none', minWidth: 'auto' }} |
||||||
|
> |
||||||
|
Accept |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{notification.status === 'accepted' && !notification.metadata?.rCardId && ( |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
startIcon={<Assignment />} |
||||||
|
onClick={handleAssignClick} |
||||||
|
sx={{ textTransform: 'none' }} |
||||||
|
> |
||||||
|
Assign to rCard |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</ListItem> |
||||||
|
|
||||||
|
{/* Menu */} |
||||||
|
<Menu |
||||||
|
anchorEl={menuAnchor} |
||||||
|
open={isMenuOpen} |
||||||
|
onClose={handleMenuClose} |
||||||
|
PaperProps={{ |
||||||
|
sx: { minWidth: 160 } |
||||||
|
}} |
||||||
|
> |
||||||
|
{!notification.isRead && ( |
||||||
|
<MenuItem onClick={handleMarkAsRead}> |
||||||
|
Mark as read |
||||||
|
</MenuItem> |
||||||
|
)} |
||||||
|
{notification.status === 'accepted' && !notification.metadata?.rCardId && ( |
||||||
|
<MenuItem onClick={handleAssignClick}> |
||||||
|
Assign to rCard |
||||||
|
</MenuItem> |
||||||
|
)} |
||||||
|
{notification.isActionable && notification.status === 'pending' && ( |
||||||
|
<> |
||||||
|
<MenuItem onClick={handleAccept}>Accept</MenuItem> |
||||||
|
<MenuItem onClick={handleReject}>Decline</MenuItem> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Menu> |
||||||
|
|
||||||
|
{/* Assign to rCard Dialog */} |
||||||
|
<Dialog
|
||||||
|
open={showAssignDialog}
|
||||||
|
onClose={() => setShowAssignDialog(false)} |
||||||
|
maxWidth="sm" |
||||||
|
fullWidth |
||||||
|
> |
||||||
|
<DialogTitle> |
||||||
|
Assign to rCard |
||||||
|
</DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> |
||||||
|
Choose which rCard category to assign this {notification.type} to. This helps organize your connections and endorsements. |
||||||
|
</Typography> |
||||||
|
|
||||||
|
<FormControl fullWidth> |
||||||
|
<InputLabel>Select rCard</InputLabel> |
||||||
|
<Select |
||||||
|
value={selectedRCard} |
||||||
|
label="Select rCard" |
||||||
|
onChange={(e) => setSelectedRCard(e.target.value)} |
||||||
|
> |
||||||
|
{DEFAULT_RCARDS.map((rCard, index) => ( |
||||||
|
<MenuItem key={index} value={`default-${index}`}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> |
||||||
|
{getRCardIcon(rCard.icon || 'PersonOutline')} |
||||||
|
<Box> |
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}> |
||||||
|
{rCard.name} |
||||||
|
</Typography> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{rCard.description} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button onClick={() => setShowAssignDialog(false)}>Cancel</Button> |
||||||
|
<Button
|
||||||
|
onClick={handleAssignSubmit}
|
||||||
|
variant="contained" |
||||||
|
disabled={!selectedRCard} |
||||||
|
> |
||||||
|
Assign |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default NotificationItem; |
@ -0,0 +1,269 @@ |
|||||||
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationSummary,
|
||||||
|
Vouch,
|
||||||
|
Praise |
||||||
|
} from '../types/notification'; |
||||||
|
|
||||||
|
// Mock data for development
|
||||||
|
const mockNotifications: Notification[] = [ |
||||||
|
{ |
||||||
|
id: '1', |
||||||
|
type: 'vouch', |
||||||
|
title: 'New Skill Vouch', |
||||||
|
message: 'Sarah Johnson vouched for your React Development skills at Advanced level', |
||||||
|
fromUserId: 'user-sarah', |
||||||
|
fromUserName: 'Sarah Johnson', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
targetUserId: 'current-user', |
||||||
|
isRead: false, |
||||||
|
isActionable: true, |
||||||
|
status: 'pending', |
||||||
|
metadata: { |
||||||
|
vouchId: 'vouch-1', |
||||||
|
}, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 30), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '2', |
||||||
|
type: 'praise', |
||||||
|
title: 'New Praise', |
||||||
|
message: 'Mike Chen praised your leadership during the Q3 project launch', |
||||||
|
fromUserId: 'user-mike', |
||||||
|
fromUserName: 'Mike Chen', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
targetUserId: 'current-user', |
||||||
|
isRead: false, |
||||||
|
isActionable: true, |
||||||
|
status: 'pending', |
||||||
|
metadata: { |
||||||
|
praiseId: 'praise-1', |
||||||
|
}, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '3', |
||||||
|
type: 'vouch', |
||||||
|
title: 'Skill Vouch Accepted', |
||||||
|
message: 'Your TypeScript vouch from Alex Rodriguez has been assigned to Business contacts', |
||||||
|
fromUserId: 'user-alex', |
||||||
|
fromUserName: 'Alex Rodriguez', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
targetUserId: 'current-user', |
||||||
|
isRead: true, |
||||||
|
isActionable: false, |
||||||
|
status: 'completed', |
||||||
|
metadata: { |
||||||
|
vouchId: 'vouch-2', |
||||||
|
rCardId: 'rcard-business', |
||||||
|
}, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 12), // Updated 12 hours ago
|
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '4', |
||||||
|
type: 'praise', |
||||||
|
title: 'Teamwork Praise', |
||||||
|
message: 'Jessica Liu praised your collaborative approach on the mobile app redesign', |
||||||
|
fromUserId: 'user-jessica', |
||||||
|
fromUserName: 'Jessica Liu', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
targetUserId: 'current-user', |
||||||
|
isRead: false, |
||||||
|
isActionable: true, |
||||||
|
status: 'accepted', |
||||||
|
metadata: { |
||||||
|
praiseId: 'praise-2', |
||||||
|
}, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 6), // 6 hours ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 4), // Updated 4 hours ago
|
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const mockVouches: Vouch[] = [ |
||||||
|
{ |
||||||
|
id: 'vouch-1', |
||||||
|
fromUserId: 'user-sarah', |
||||||
|
fromUserName: 'Sarah Johnson', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
toUserId: 'current-user', |
||||||
|
skill: 'React Development', |
||||||
|
description: 'Excellent component architecture and state management. Always writes clean, maintainable code.', |
||||||
|
level: 'advanced', |
||||||
|
endorsementText: 'I worked with this person on multiple React projects and they consistently delivered high-quality solutions.', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 30), |
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 30), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'vouch-2', |
||||||
|
fromUserId: 'user-alex', |
||||||
|
fromUserName: 'Alex Rodriguez', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
toUserId: 'current-user', |
||||||
|
skill: 'TypeScript', |
||||||
|
description: 'Strong type safety practices and excellent knowledge of advanced TypeScript features.', |
||||||
|
level: 'expert', |
||||||
|
endorsementText: 'One of the best TypeScript developers I have worked with.', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24), |
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24), |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const mockPraises: Praise[] = [ |
||||||
|
{ |
||||||
|
id: 'praise-1', |
||||||
|
fromUserId: 'user-mike', |
||||||
|
fromUserName: 'Mike Chen', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
toUserId: 'current-user', |
||||||
|
category: 'leadership', |
||||||
|
title: 'Outstanding Project Leadership', |
||||||
|
description: 'Led the Q3 project launch with exceptional coordination and communication. Kept the team motivated and on track.', |
||||||
|
tags: ['project-management', 'team-leadership', 'communication'], |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2), |
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'praise-2', |
||||||
|
fromUserId: 'user-jessica', |
||||||
|
fromUserName: 'Jessica Liu', |
||||||
|
fromUserAvatar: undefined, |
||||||
|
toUserId: 'current-user', |
||||||
|
category: 'teamwork', |
||||||
|
title: 'Collaborative Team Player', |
||||||
|
description: 'Always willing to help teammates and shares knowledge freely. Made the mobile app redesign a huge success.', |
||||||
|
tags: ['collaboration', 'mobile-development', 'mentoring'], |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 6), |
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 6), |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export class NotificationService { |
||||||
|
private notifications: Notification[] = [...mockNotifications]; |
||||||
|
private vouches: Vouch[] = [...mockVouches]; |
||||||
|
private praises: Praise[] = [...mockPraises]; |
||||||
|
|
||||||
|
// Get all notifications for a user
|
||||||
|
async getNotifications(userId: string): Promise<Notification[]> { |
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300)); |
||||||
|
return this.notifications.filter(n => n.targetUserId === userId); |
||||||
|
} |
||||||
|
|
||||||
|
// Get notification summary
|
||||||
|
async getNotificationSummary(userId: string): Promise<NotificationSummary> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 100)); |
||||||
|
const userNotifications = this.notifications.filter(n => n.targetUserId === userId); |
||||||
|
|
||||||
|
const summary: NotificationSummary = { |
||||||
|
total: userNotifications.length, |
||||||
|
unread: userNotifications.filter(n => !n.isRead).length, |
||||||
|
pending: userNotifications.filter(n => n.status === 'pending' && n.isActionable).length, |
||||||
|
byType: { |
||||||
|
vouch: userNotifications.filter(n => n.type === 'vouch').length, |
||||||
|
praise: userNotifications.filter(n => n.type === 'praise').length, |
||||||
|
connection: userNotifications.filter(n => n.type === 'connection').length, |
||||||
|
group_invite: userNotifications.filter(n => n.type === 'group_invite').length, |
||||||
|
message: userNotifications.filter(n => n.type === 'message').length, |
||||||
|
system: userNotifications.filter(n => n.type === 'system').length, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
return summary; |
||||||
|
} |
||||||
|
|
||||||
|
// Mark notification as read
|
||||||
|
async markAsRead(notificationId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)); |
||||||
|
const notification = this.notifications.find(n => n.id === notificationId); |
||||||
|
if (notification) { |
||||||
|
notification.isRead = true; |
||||||
|
notification.updatedAt = new Date(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Mark all notifications as read for a user
|
||||||
|
async markAllAsRead(userId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 300)); |
||||||
|
this.notifications |
||||||
|
.filter(n => n.targetUserId === userId && !n.isRead) |
||||||
|
.forEach(notification => { |
||||||
|
notification.isRead = true; |
||||||
|
notification.updatedAt = new Date(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Accept a vouch
|
||||||
|
async acceptVouch(notificationId: string, _vouchId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 400)); |
||||||
|
const notification = this.notifications.find(n => n.id === notificationId); |
||||||
|
if (notification) { |
||||||
|
notification.status = 'accepted'; |
||||||
|
notification.isActionable = true; // Still actionable for rCard assignment
|
||||||
|
notification.updatedAt = new Date(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Reject a vouch
|
||||||
|
async rejectVouch(notificationId: string, _vouchId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 400)); |
||||||
|
const notification = this.notifications.find(n => n.id === notificationId); |
||||||
|
if (notification) { |
||||||
|
notification.status = 'rejected'; |
||||||
|
notification.isActionable = false; |
||||||
|
notification.updatedAt = new Date(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Accept praise
|
||||||
|
async acceptPraise(notificationId: string, _praiseId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 400)); |
||||||
|
const notification = this.notifications.find(n => n.id === notificationId); |
||||||
|
if (notification) { |
||||||
|
notification.status = 'accepted'; |
||||||
|
notification.isActionable = true; // Still actionable for rCard assignment
|
||||||
|
notification.updatedAt = new Date(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Reject praise
|
||||||
|
async rejectPraise(notificationId: string, _praiseId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 400)); |
||||||
|
const notification = this.notifications.find(n => n.id === notificationId); |
||||||
|
if (notification) { |
||||||
|
notification.status = 'rejected'; |
||||||
|
notification.isActionable = false; |
||||||
|
notification.updatedAt = new Date(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Assign to rCard
|
||||||
|
async assignToRCard(notificationId: string, rCardId: string): Promise<void> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 300)); |
||||||
|
const notification = this.notifications.find(n => n.id === notificationId); |
||||||
|
if (notification && notification.metadata) { |
||||||
|
notification.metadata.rCardId = rCardId; |
||||||
|
notification.status = 'completed'; |
||||||
|
notification.isActionable = false; |
||||||
|
notification.isRead = true; |
||||||
|
notification.updatedAt = new Date(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Get vouch details
|
||||||
|
async getVouch(vouchId: string): Promise<Vouch | null> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)); |
||||||
|
return this.vouches.find(v => v.id === vouchId) || null; |
||||||
|
} |
||||||
|
|
||||||
|
// Get praise details
|
||||||
|
async getPraise(praiseId: string): Promise<Praise | null> { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)); |
||||||
|
return this.praises.find(p => p.id === praiseId) || null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const notificationService = new NotificationService(); |
@ -0,0 +1,146 @@ |
|||||||
|
export interface RCard { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
description?: string; |
||||||
|
color?: string; |
||||||
|
icon?: string; |
||||||
|
isDefault: boolean; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Vouch { |
||||||
|
id: string; |
||||||
|
fromUserId: string; |
||||||
|
fromUserName: string; |
||||||
|
fromUserAvatar?: string; |
||||||
|
toUserId: string; |
||||||
|
skill: string; |
||||||
|
description: string; |
||||||
|
level: 'beginner' | 'intermediate' | 'advanced' | 'expert'; |
||||||
|
endorsementText?: string; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Praise { |
||||||
|
id: string; |
||||||
|
fromUserId: string; |
||||||
|
fromUserName: string; |
||||||
|
fromUserAvatar?: string; |
||||||
|
toUserId: string; |
||||||
|
category: 'professional' | 'personal' | 'leadership' | 'teamwork' | 'communication' | 'creativity' | 'other'; |
||||||
|
title: string; |
||||||
|
description: string; |
||||||
|
tags?: string[]; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
export interface NotificationAction { |
||||||
|
id: string; |
||||||
|
type: 'accept' | 'reject' | 'assign' | 'view'; |
||||||
|
label: string; |
||||||
|
variant?: 'text' | 'outlined' | 'contained'; |
||||||
|
color?: 'primary' | 'secondary' | 'success' | 'error' | 'warning'; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Notification { |
||||||
|
id: string; |
||||||
|
type: 'vouch' | 'praise' | 'connection' | 'group_invite' | 'message' | 'system'; |
||||||
|
title: string; |
||||||
|
message: string; |
||||||
|
fromUserId?: string; |
||||||
|
fromUserName?: string; |
||||||
|
fromUserAvatar?: string; |
||||||
|
targetUserId: string; |
||||||
|
isRead: boolean; |
||||||
|
isActionable: boolean; |
||||||
|
status: 'pending' | 'accepted' | 'rejected' | 'completed'; |
||||||
|
actions?: NotificationAction[]; |
||||||
|
metadata?: { |
||||||
|
vouchId?: string; |
||||||
|
praiseId?: string; |
||||||
|
groupId?: string; |
||||||
|
messageId?: string; |
||||||
|
rCardId?: string; |
||||||
|
}; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
export interface VouchNotification extends Notification { |
||||||
|
type: 'vouch'; |
||||||
|
metadata: { |
||||||
|
vouchId: string; |
||||||
|
rCardId?: string; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PraiseNotification extends Notification { |
||||||
|
type: 'praise'; |
||||||
|
metadata: { |
||||||
|
praiseId: string; |
||||||
|
rCardId?: string; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface NotificationSummary { |
||||||
|
total: number; |
||||||
|
unread: number; |
||||||
|
pending: number; |
||||||
|
byType: { |
||||||
|
vouch: number; |
||||||
|
praise: number; |
||||||
|
connection: number; |
||||||
|
group_invite: number; |
||||||
|
message: number; |
||||||
|
system: number; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Default rCard categories
|
||||||
|
export const DEFAULT_RCARDS: Omit<RCard, 'id' | 'createdAt' | 'updatedAt'>[] = [ |
||||||
|
{ |
||||||
|
name: 'Business', |
||||||
|
description: 'Professional business contacts and partnerships', |
||||||
|
color: '#2563eb', |
||||||
|
icon: 'Business', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: 'Acquaintance', |
||||||
|
description: 'People you know casually or have met briefly', |
||||||
|
color: '#10b981', |
||||||
|
icon: 'PersonOutline', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: 'Colleague', |
||||||
|
description: 'Current and former work colleagues', |
||||||
|
color: '#8b5cf6', |
||||||
|
icon: 'Groups', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: 'Family', |
||||||
|
description: 'Extended family members and relatives', |
||||||
|
color: '#f59e0b', |
||||||
|
icon: 'FamilyRestroom', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: 'Friend', |
||||||
|
description: 'Personal friends and social connections', |
||||||
|
color: '#ef4444', |
||||||
|
icon: 'Favorite', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: 'Close Family', |
||||||
|
description: 'Immediate family members', |
||||||
|
color: '#ec4899', |
||||||
|
icon: 'Home', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
]; |
Loading…
Reference in new issue