Files
Krow-workspace/frontend-web/src/components/notifications/NotificationPanel.jsx
2025-11-21 09:13:05 -05:00

622 lines
29 KiB
JavaScript

import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
X,
Bell,
Calendar,
UserPlus,
FileText,
MessageSquare,
AlertCircle,
CheckCircle,
ArrowRight,
MoreVertical,
CheckSquare,
Package
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
const iconMap = {
calendar: Calendar,
user: UserPlus,
invoice: FileText,
message: MessageSquare,
alert: AlertCircle,
check: CheckCircle,
};
const colorMap = {
blue: "bg-blue-100 text-blue-600",
red: "bg-red-100 text-red-600",
green: "bg-green-100 text-green-600",
yellow: "bg-yellow-100 text-yellow-600",
purple: "bg-purple-100 text-purple-600",
};
export default function NotificationPanel({ isOpen, onClose }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeFilter, setActiveFilter] = useState('all');
const { data: user } = useQuery({
queryKey: ['current-user-notifications'],
queryFn: () => base44.auth.me(),
});
const { data: notifications = [] } = useQuery({
queryKey: ['activity-logs', user?.id],
queryFn: async () => {
if (!user?.id) return [];
// Create sample notifications if none exist
const existing = await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
if (existing.length === 0 && user?.id) {
// Create initial sample notifications
await base44.entities.ActivityLog.bulkCreate([
{
title: "Event Rescheduled",
description: "Team Meeting was moved to July 15, 3:00 PM",
activity_type: "event_rescheduled",
related_entity_type: "event",
action_label: "View Event",
icon_type: "calendar",
icon_color: "blue",
is_read: false,
user_id: user.id
},
{
title: "Event Canceled",
description: "Product Demo scheduled for May 20 has been canceled",
activity_type: "event_canceled",
related_entity_type: "event",
action_label: "View Event",
icon_type: "calendar",
icon_color: "red",
is_read: false,
user_id: user.id
},
{
title: "Invoice Paid",
description: "You've been added to Client Kickoff on June 8, 10:00 AM",
activity_type: "invoice_paid",
related_entity_type: "invoice",
action_label: "View Invoice",
icon_type: "invoice",
icon_color: "green",
is_read: false,
user_id: user.id
},
{
title: "Staff Selected",
description: "10 staff members selected to fill remaining 10 slots",
activity_type: "staff_assigned",
related_entity_type: "event",
icon_type: "user",
icon_color: "purple",
is_read: true,
user_id: user.id
}
]);
return await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
}
return existing;
},
enabled: !!user?.id,
initialData: [],
});
const markAsReadMutation = useMutation({
mutationFn: ({ id }) => base44.entities.ActivityLog.update(id, { is_read: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
},
});
const deleteMutation = useMutation({
mutationFn: ({ id }) => base44.entities.ActivityLog.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
},
});
// Categorize by type
const categorizeByType = (notif) => {
const type = notif.activity_type || '';
const title = (notif.title || '').toLowerCase();
if (type.includes('message') || title.includes('message') || title.includes('comment') || title.includes('mentioned')) {
return 'mentions';
} else if (type.includes('staff_assigned') || type.includes('user') || title.includes('invited') || title.includes('followed')) {
return 'invites';
} else {
return 'all';
}
};
// Filter notifications based on active filter
const filteredNotifications = notifications.filter(notif => {
if (activeFilter === 'all') return true;
return categorizeByType(notif) === activeFilter;
});
// Group by day
const groupByDay = (notifList) => {
const groups = {
today: [],
yesterday: [],
thisWeek: [],
older: []
};
notifList.forEach(notif => {
const date = new Date(notif.created_date);
if (isToday(date)) {
groups.today.push(notif);
} else if (isYesterday(date)) {
groups.yesterday.push(notif);
} else if (isThisWeek(date)) {
groups.thisWeek.push(notif);
} else {
groups.older.push(notif);
}
});
return groups;
};
const groupedNotifications = groupByDay(filteredNotifications);
// Count by type
const allCount = notifications.length;
const mentionsCount = notifications.filter(n => categorizeByType(n) === 'mentions').length;
const invitesCount = notifications.filter(n => categorizeByType(n) === 'invites').length;
const handleAction = (notification) => {
// Mark as read when clicking
if (!notification.is_read) {
markAsReadMutation.mutate({ id: notification.id });
}
const entityType = notification.related_entity_type;
const entityId = notification.related_entity_id;
const activityType = notification.activity_type || '';
// Route based on entity type
if (entityType === 'event' || activityType.includes('event') || activityType.includes('order')) {
if (entityId) {
navigate(createPageUrl(`EventDetail?id=${entityId}`));
} else {
navigate(createPageUrl('Events'));
}
} else if (entityType === 'task' || activityType.includes('task')) {
navigate(createPageUrl('TaskBoard'));
} else if (entityType === 'invoice' || activityType.includes('invoice')) {
if (entityId) {
navigate(createPageUrl(`Invoices?id=${entityId}`));
} else {
navigate(createPageUrl('Invoices'));
}
} else if (entityType === 'staff' || activityType.includes('staff')) {
if (entityId) {
navigate(createPageUrl(`EditStaff?id=${entityId}`));
} else {
navigate(createPageUrl('StaffDirectory'));
}
} else if (entityType === 'message' || activityType.includes('message')) {
navigate(createPageUrl('Messages'));
} else if (notification.action_link) {
navigate(createPageUrl(notification.action_link));
}
onClose();
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/20 z-40"
/>
{/* Panel */}
<motion.div
initial={{ opacity: 0, x: 300 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 300 }}
transition={{ type: "spring", damping: 25 }}
className="fixed right-0 top-0 h-full w-full sm:w-[440px] bg-white shadow-2xl z-50 flex flex-col"
>
{/* Header */}
<div className="border-b border-slate-200">
<div className="flex items-center justify-between p-6">
<div className="flex items-center gap-3">
<Bell className="w-6 h-6 text-[#1C323E]" />
<h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-2 px-6 pb-4">
<button
onClick={() => setActiveFilter('all')}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
activeFilter === 'all'
? 'bg-blue-50 text-blue-600'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
View all <span className="ml-1">{allCount}</span>
</button>
<button
onClick={() => setActiveFilter('mentions')}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
activeFilter === 'mentions'
? 'bg-blue-50 text-blue-600'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Mentions <span className="ml-1">{mentionsCount}</span>
</button>
<button
onClick={() => setActiveFilter('invites')}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
activeFilter === 'invites'
? 'bg-blue-50 text-blue-600'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Invites <span className="ml-1">{invitesCount}</span>
</button>
</div>
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{filteredNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<Bell className="w-16 h-16 text-slate-300 mb-4" />
<h3 className="text-lg font-semibold text-slate-900 mb-2">No notifications</h3>
<p className="text-slate-600">You're all caught up!</p>
</div>
) : (
<>
{/* TODAY */}
{groupedNotifications.today.length > 0 && (
<div className="px-6 py-4">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">TODAY</h3>
<div className="space-y-4">
{groupedNotifications.today.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => handleAction(notification)}
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
notification.icon_color === 'blue' ? 'bg-blue-100' :
notification.icon_color === 'green' ? 'bg-green-100' :
notification.icon_color === 'red' ? 'bg-red-100' :
notification.icon_color === 'purple' ? 'bg-purple-100' :
'bg-slate-100'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => handleAction(notification)}
>
<div className="flex items-start justify-between mb-1">
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* YESTERDAY */}
{groupedNotifications.yesterday.length > 0 && (
<div className="px-6 py-4 border-t border-slate-100">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">YESTERDAY</h3>
<div className="space-y-4">
{groupedNotifications.yesterday.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => handleAction(notification)}
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
notification.icon_color === 'blue' ? 'bg-blue-100' :
notification.icon_color === 'green' ? 'bg-green-100' :
notification.icon_color === 'red' ? 'bg-red-100' :
notification.icon_color === 'purple' ? 'bg-purple-100' :
'bg-slate-100'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0"
onClick={() => notification.action_link && handleAction(notification)}
>
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* THIS WEEK */}
{groupedNotifications.thisWeek.length > 0 && (
<div className="px-6 py-4 border-t border-slate-100">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">THIS WEEK</h3>
<div className="space-y-4">
{groupedNotifications.thisWeek.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-80 hover:opacity-100">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => handleAction(notification)}
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
notification.icon_color === 'blue' ? 'bg-blue-100' :
notification.icon_color === 'green' ? 'bg-green-100' :
notification.icon_color === 'red' ? 'bg-red-100' :
notification.icon_color === 'purple' ? 'bg-purple-100' :
'bg-slate-100'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0"
onClick={() => notification.action_link && handleAction(notification)}
>
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* OLDER */}
{groupedNotifications.older.length > 0 && (
<div className="px-6 py-4 border-t border-slate-100">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">OLDER</h3>
<div className="space-y-4">
{groupedNotifications.older.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-70 hover:opacity-100">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => handleAction(notification)}
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
notification.icon_color === 'blue' ? 'bg-blue-100' :
notification.icon_color === 'green' ? 'bg-green-100' :
notification.icon_color === 'red' ? 'bg-red-100' :
notification.icon_color === 'purple' ? 'bg-purple-100' :
'bg-slate-100'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0"
onClick={() => notification.action_link && handleAction(notification)}
>
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}