Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
'use client';
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();
const { data, isLoading } = useQuery<NotificationListResponse>({
queryKey: ['notifications', 'list'],
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
staleTime: 30_000,
});
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>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-bold text-white">
{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>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { formatDistanceToNow } from 'date-fns';
import { useRouter } from 'next/navigation';
interface NotificationItemProps {
notification: {
id: string;
type: string;
title: string;
description: string | null;
link: string | null;
isRead: boolean;
createdAt: Date;
};
onMarkRead: (id: string) => void;
}
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const router = useRouter();
const handleClick = () => {
if (!notification.isRead) {
onMarkRead(notification.id);
}
if (notification.link) {
router.push(notification.link as any);
}
};
return (
<button
type="button"
onClick={handleClick}
className="w-full text-left flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
>
{/* Unread indicator */}
<div className="mt-1.5 flex-shrink-0">
{!notification.isRead ? (
<span className="block h-2 w-2 rounded-full bg-blue-500" />
) : (
<span className="block h-2 w-2 rounded-full bg-transparent" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-snug truncate ${
notification.isRead ? 'text-muted-foreground' : 'text-foreground font-medium'
}`}
>
{notification.title}
</p>
{notification.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{notification.description}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
</button>
);
}