Files
pn-new-crm/src/components/notifications/notification-bell.tsx
Matt Ciaccio e598cc0708 feat(layout): unified Inbox + UserMenu extraction
Replaces the topbar's separate AlertBell + NotificationBell with a
single Inbox popover that tabs between alerts and notifications.
NotificationBell keeps a popover-gate so it doesn't fire its list
fetch when Inbox is mounted alongside it.

Extracts the user dropdown into <UserMenu> and moves the port
switcher + role label + theme toggle into the sidebar footer so
the topbar can reclaim space for breadcrumbs and command search.

Adds dedicated Insights / Receipts nav sections in the sidebar
(scaffolds the website-analytics + upload-receipts entry points).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:06 +02:00

122 lines
4.2 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Bell } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { apiFetch } from '@/lib/api/client';
import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from './notification-item';
interface NotificationListResponse {
data: Array<{
id: string;
type: string;
title: string;
description: string | null;
link: string | null;
isRead: boolean;
createdAt: Date;
}>;
total: number;
}
export function NotificationBell() {
const { unreadCount } = useNotifications();
const queryClient = useQueryClient();
// Track popover open state so we only fire the list fetch when the user
// actually opens the bell. Without this, an instance of NotificationBell
// mounted alongside <Inbox /> would populate the same ['notifications',
// 'list'] cache key without the gating Inbox carefully applies, defeating
// Inbox's enabled-on-open optimization.
const [open, setOpen] = useState(false);
const { data, isLoading } = useQuery<NotificationListResponse>({
queryKey: ['notifications', 'list'],
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
staleTime: 30_000,
enabled: open,
});
const markReadMutation = useMutation({
mutationFn: (notificationId: string) =>
apiFetch(`/api/v1/notifications/${notificationId}`, { method: 'PATCH' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const markAllReadMutation = useMutation({
mutationFn: () => apiFetch('/api/v1/notifications/read-all', { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const notifications = data?.data ?? [];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span
key={unreadCount}
className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-gradient-brand text-[10px] font-bold text-white shadow-sm ring-2 ring-background animate-badge-pop"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3">
<h4 className="text-sm font-semibold">Notifications</h4>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
Mark all read
</Button>
)}
</div>
<Separator />
{/* Notification list */}
<ScrollArea className="max-h-[400px]">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-sm text-muted-foreground">
<Bell className="mb-2 h-8 w-8 opacity-30" />
No notifications
</div>
) : (
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkRead={(id) => markReadMutation.mutate(id)}
/>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}