140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Loader2,
|
|
CheckCheck,
|
|
FlaskConical,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
XCircle,
|
|
Bell,
|
|
} from "lucide-react";
|
|
import {
|
|
getNotifications,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
type NotificationItem,
|
|
} from "../api/notifications";
|
|
|
|
const typeIcons: Record<string, React.ReactNode> = {
|
|
test_assigned: <FlaskConical className="h-4 w-4 text-indigo-400" />,
|
|
validation_needed: <AlertTriangle className="h-4 w-4 text-yellow-400" />,
|
|
test_rejected: <XCircle className="h-4 w-4 text-red-400" />,
|
|
test_validated: <CheckCircle className="h-4 w-4 text-green-400" />,
|
|
test_state_changed: <Bell className="h-4 w-4 text-cyan-400" />,
|
|
};
|
|
|
|
export default function NotificationDropdown({ onClose }: { onClose: () => void }) {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: notifications, isLoading } = useQuery({
|
|
queryKey: ["notifications", "list"],
|
|
queryFn: () => getNotifications(0, 20),
|
|
});
|
|
|
|
const markReadMutation = useMutation({
|
|
mutationFn: markAsRead,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
},
|
|
});
|
|
|
|
const markAllMutation = useMutation({
|
|
mutationFn: markAllAsRead,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
},
|
|
});
|
|
|
|
const handleClick = (notif: NotificationItem) => {
|
|
if (!notif.read) {
|
|
markReadMutation.mutate(notif.id);
|
|
}
|
|
if (notif.entity_type === "test" && notif.entity_id) {
|
|
navigate(`/tests/${notif.entity_id}`);
|
|
} else if (notif.entity_type === "technique" && notif.entity_id) {
|
|
navigate(`/techniques/${notif.entity_id}`);
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
const formatTime = (dateStr: string | null) => {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - d.getTime();
|
|
const diffMin = Math.floor(diffMs / 60000);
|
|
if (diffMin < 1) return "just now";
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
|
const diffH = Math.floor(diffMin / 60);
|
|
if (diffH < 24) return `${diffH}h ago`;
|
|
const diffD = Math.floor(diffH / 24);
|
|
return `${diffD}d ago`;
|
|
};
|
|
|
|
return (
|
|
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-gray-800 bg-gray-900 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-3">
|
|
<h3 className="text-sm font-semibold text-white">Notifications</h3>
|
|
<button
|
|
onClick={() => markAllMutation.mutate()}
|
|
disabled={markAllMutation.isPending}
|
|
className="flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
|
>
|
|
<CheckCheck className="h-3.5 w-3.5" />
|
|
Mark all read
|
|
</button>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : notifications && notifications.length > 0 ? (
|
|
notifications.map((notif) => (
|
|
<button
|
|
key={notif.id}
|
|
onClick={() => handleClick(notif)}
|
|
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-800/50 ${
|
|
!notif.read ? "bg-cyan-500/5" : ""
|
|
}`}
|
|
>
|
|
<div className="mt-0.5 flex-shrink-0">
|
|
{typeIcons[notif.type] || <Bell className="h-4 w-4 text-gray-400" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className={`text-sm ${
|
|
notif.read ? "text-gray-400" : "font-medium text-white"
|
|
}`}
|
|
>
|
|
{notif.title}
|
|
</p>
|
|
{notif.message && (
|
|
<p className="mt-0.5 text-xs text-gray-500 truncate">
|
|
{notif.message}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-[10px] text-gray-600">
|
|
{formatTime(notif.created_at)}
|
|
</p>
|
|
</div>
|
|
{!notif.read && (
|
|
<div className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-cyan-400" />
|
|
)}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="py-8 text-center text-sm text-gray-500">
|
|
No notifications yet
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|