Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
'use client';
|
|
|
|
|
|
2026-05-04 22:54:06 +02:00
|
|
|
import { useState } from 'react';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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();
|
2026-05-04 22:54:06 +02:00
|
|
|
// 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);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const { data, isLoading } = useQuery<NotificationListResponse>({
|
|
|
|
|
queryKey: ['notifications', 'list'],
|
|
|
|
|
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
|
|
|
|
|
staleTime: 30_000,
|
2026-05-04 22:54:06 +02:00
|
|
|
enabled: open,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-05-04 22:54:06 +02:00
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button variant="ghost" size="icon" className="relative">
|
|
|
|
|
<Bell className="h-5 w-5" />
|
|
|
|
|
{unreadCount > 0 && (
|
2026-04-28 12:10:11 +02:00
|
|
|
<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"
|
|
|
|
|
>
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|