Qassure-frontend/src/pages/tenant/Notifications.tsx

418 lines
15 KiB
TypeScript

import { useState, useEffect, useMemo } from "react";
import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
import {
fetchNotifications,
markReadAsync,
readAllAsync,
} from "@/store/notificationSlice";
import {
Bell,
Search,
Check,
Trash2,
CheckCircle2,
AlertCircle,
AlertTriangle,
Info,
Clipboard,
FileText,
GraduationCap,
GitGraph,
Building2,
ExternalLink,
Clock,
} from "lucide-react";
import { formatDistanceToNow, isToday, isYesterday } from "date-fns";
import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom";
import type {
Notification,
NotificationType,
NotificationCategory,
} from "@/types/notification";
import { Layout } from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { notificationService } from "@/services/notification-service";
import { showToast } from "@/utils/toast";
const getNotificationIcon = (
category?: NotificationCategory,
type?: NotificationType,
) => {
switch (category) {
case "capa":
return <Clipboard className="w-5 h-5" />;
case "document":
return <FileText className="w-5 h-5" />;
case "training":
return <GraduationCap className="w-5 h-5" />;
case "workflow":
return <GitGraph className="w-5 h-5" />;
case "supplier":
return <Building2 className="w-5 h-5" />;
case "system":
return <Bell className="w-5 h-5" />;
default:
switch (type) {
case "success":
return <CheckCircle2 className="w-5 h-5" />;
case "warning":
return <AlertTriangle className="w-5 h-5" />;
case "action_required":
return <AlertCircle className="w-5 h-5" />;
default:
return <Info className="w-5 h-5" />;
}
}
};
const getTypeColors = (type: NotificationType) => {
switch (type) {
case "success":
return "bg-green-100 text-green-600 border-green-200";
case "warning":
return "bg-orange-100 text-orange-600 border-orange-200";
case "action_required":
return "bg-amber-100 text-amber-600 border-amber-200";
case "escalation":
return "bg-red-100 text-red-600 border-red-200";
default:
return "bg-blue-100 text-blue-600 border-blue-200";
}
};
type FilterType = "all" | "unread" | "tasks" | "mentions" | "system";
export const Notifications = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { notifications, unread_count, isLoading } = useAppSelector(
(state) => state.notifications,
);
const [activeFilter, setActiveFilter] = useState<FilterType>("all");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
dispatch(fetchNotifications({ limit: 100 }));
}, [dispatch]);
const filteredNotifications = useMemo(() => {
return notifications.filter((n) => {
// Filter by tab
if (activeFilter === "unread" && n.is_read) return false;
if (
activeFilter === "tasks" &&
!["workflow", "training", "capa"].includes(n.category || "")
)
return false;
if (
activeFilter === "mentions" &&
n.notification_type !== "action_required"
)
return false; // Approximation
if (activeFilter === "system" && n.category !== "system") return false;
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
n.title.toLowerCase().includes(query) ||
n.message.toLowerCase().includes(query)
);
}
return true;
});
}, [notifications, activeFilter, searchQuery]);
const counts = useMemo(
() => ({
all: notifications.length,
unread: unread_count,
tasks: notifications.filter((n) =>
["workflow", "training", "capa"].includes(n.category || ""),
).length,
mentions: notifications.filter(
(n) => n.notification_type === "action_required",
).length,
system: notifications.filter((n) => n.category === "system").length,
}),
[notifications, unread_count],
);
const handleMarkRead = (id: string) => {
dispatch(markReadAsync(id));
};
const handleReadAll = () => {
dispatch(readAllAsync());
};
const handleDismiss = async (id: string) => {
try {
await notificationService.dismiss(id);
dispatch(fetchNotifications({ limit: 100 }));
showToast.success("Notification dismissed");
} catch (error) {
showToast.error("Failed to dismiss notification");
}
};
const handleAction = (notification: Notification) => {
if (!notification.is_read) {
handleMarkRead(notification.id);
}
// Special handling for tasks as requested - redirect to My Tasks tab
if (["workflow", "training"].includes(notification.category || "")) {
navigate("/tenant/workflows/tasks");
return;
}
// Special handling for system as requested - redirect to Dashboard
// if (["system"].includes(notification.category || "")) {
// navigate("/tenant");
// return;
// }
if (notification.action_url) {
const targetUrl = notification.action_url.startsWith('/tenant')
? notification.action_url
: `/tenant${notification.action_url.startsWith('/') ? '' : '/'}${notification.action_url}`;
navigate(targetUrl);
}
};
const formatDate = (date: string) => {
const d = new Date(date);
if (isToday(d)) return formatDistanceToNow(d, { addSuffix: true });
if (isYesterday(d)) return "Yesterday";
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<Layout
currentPage="Notifications"
breadcrumbs={[
{ label: "Home", path: "/tenant" },
{ label: "Notifications" },
]}
>
<div className="max-w-6xl mx-auto space-y-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 tracking-tight">
Notifications
</h1>
<p className="text-gray-500 mt-1.5 flex items-center gap-2">
Stay on top of mentions, tasks, and system updates in one place.
</p>
</div>
{/* Filters and Tabs */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-200">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
{(
["all", "unread", "tasks", "mentions", "system"] as FilterType[]
).map((filter) => (
<button
key={filter}
onClick={() => setActiveFilter(filter)}
className={cn(
"relative px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap flex items-center gap-2",
activeFilter === filter
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700",
)}
>
<span className="capitalize">{filter}</span>
{counts[filter] > 0 && (
<span
className={cn(
"flex items-center justify-center w-5 h-5 text-[10px] rounded-full",
activeFilter === filter
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-500",
)}
>
{counts[filter]}
</span>
)}
</button>
))}
</div>
<div className="flex items-center gap-3 pb-2 md:pb-0">
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Search notifications..."
className="pl-9 pr-4 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50/50 outline-none focus:bg-white focus:ring-2 focus:ring-blue-100 focus:border-blue-400 transition-all w-full md:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{unread_count > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleReadAll}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
>
<Check className="w-4 h-4 mr-2" />
Mark all as read
</Button>
)}
</div>
</div>
{/* Content Area */}
<div className="space-y-4">
{isLoading ? (
<div className="py-20 text-center text-gray-400">
Loading notifications...
</div>
) : filteredNotifications.length === 0 ? (
<div className="py-24 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mb-4">
<Bell className="w-8 h-8 text-blue-200" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
No notifications found
</h3>
<p className="text-sm text-gray-500 mt-1 max-w-xs mx-auto">
{activeFilter === "unread"
? "You have caught up with all your notifications!"
: "We couldn't find any notifications matching your current filters."}
</p>
<Button
variant="outline"
className="mt-6"
onClick={() => {
setActiveFilter("all");
setSearchQuery("");
}}
>
Clear all filters
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{filteredNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
"group relative overflow-hidden bg-white border rounded-xl transition-all duration-200 hover:shadow-md",
!notification.is_read
? "border-blue-100 shadow-[0_2px_8px_rgba(59,130,246,0.05)]"
: "border-gray-100 shadow-sm",
)}
>
{/* Unread indicator bar */}
{!notification.is_read && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500" />
)}
<div className="p-4 md:p-5 flex gap-4">
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center border",
getTypeColors(notification.notification_type),
)}
>
{getNotificationIcon(
notification.category,
notification.notification_type,
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-1 mb-1">
<div className="flex items-center gap-2">
<h4
className={cn(
"text-sm md:text-base font-semibold text-gray-900 truncate",
!notification.is_read && "text-blue-900",
)}
>
{notification.title}
</h4>
{notification.priority === "urgent" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-600 text-[10px] font-bold rounded uppercase tracking-wider">
Urgent
</span>
)}
</div>
<span className="text-xs text-gray-400 font-medium">
{formatDate(notification.created_at)}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed max-w-3xl">
{notification.message}
</p>
{notification.entity_name && (
<div className="mt-3 flex items-center gap-2 text-[11px] font-medium text-gray-400">
<span className="px-1.5 py-0.5 bg-gray-50 border border-gray-100 rounded text-gray-500">
{notification.entity_type?.replace(/_/g, " ")}
</span>
<span className="px-1.5 py-0.5 bg-gray-50 border border-gray-100 rounded text-gray-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{notification.entity_name}
</span>
</div>
)}
{/* Actions */}
<div className="mt-4 pt-4 border-t border-gray-50 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
size="sm"
className="bg-[#112868] hover:bg-[#0a1b4d] text-white"
onClick={() => handleAction(notification)}
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
{notification.category === "training"
? "View Assignment"
: notification.category === "capa"
? "View CAPA"
: notification.category === "workflow"
? "View Task"
: "View Details"}
</Button>
{!notification.is_read && (
<button
onClick={() => handleMarkRead(notification.id)}
className="text-xs font-medium text-gray-500 hover:text-blue-600 transition-colors flex items-center"
>
<Check className="w-3.5 h-3.5 mr-1" />
Mark as read
</button>
)}
</div>
<button
onClick={() => handleDismiss(notification.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</Layout>
);
};
export default Notifications;