diff --git a/App/pelagia-portal/app/(portal)/layout.tsx b/App/pelagia-portal/app/(portal)/layout.tsx
index d563085..bae3fe7 100644
--- a/App/pelagia-portal/app/(portal)/layout.tsx
+++ b/App/pelagia-portal/app/(portal)/layout.tsx
@@ -1,4 +1,5 @@
import { auth } from "@/auth";
+import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
@@ -11,11 +12,33 @@ export default async function PortalLayout({
const session = await auth();
if (!session?.user) redirect("/login");
+ const [notifications, unreadCount] = await Promise.all([
+ db.notification.findMany({
+ where: { userId: session.user.id },
+ orderBy: { sentAt: "desc" },
+ take: 20,
+ select: { id: true, body: true, link: true, isRead: true, sentAt: true, poId: true },
+ }),
+ db.notification.count({
+ where: { userId: session.user.id, isRead: false },
+ }),
+ ]);
+
+ // Dates must be serialised before being passed to Client Components
+ const serialisedNotifications = notifications.map((n) => ({
+ ...n,
+ sentAt: n.sentAt.toISOString(),
+ }));
+
return (
diff --git a/App/pelagia-portal/app/api/notifications/read/route.ts b/App/pelagia-portal/app/api/notifications/read/route.ts
new file mode 100644
index 0000000..434d9de
--- /dev/null
+++ b/App/pelagia-portal/app/api/notifications/read/route.ts
@@ -0,0 +1,32 @@
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { NextRequest, NextResponse } from "next/server";
+
+// PATCH /api/notifications/read
+// Body: { ids?: string[] } — if omitted, mark all unread as read
+export async function PATCH(request: NextRequest) {
+ const session = await auth();
+ if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ let ids: string[] | undefined;
+ try {
+ const body = await request.json();
+ ids = body.ids;
+ } catch {
+ // no body — mark all
+ }
+
+ if (ids && ids.length > 0) {
+ await db.notification.updateMany({
+ where: { id: { in: ids }, userId: session.user.id },
+ data: { isRead: true },
+ });
+ } else {
+ await db.notification.updateMany({
+ where: { userId: session.user.id, isRead: false },
+ data: { isRead: true },
+ });
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/App/pelagia-portal/app/api/notifications/route.ts b/App/pelagia-portal/app/api/notifications/route.ts
new file mode 100644
index 0000000..f30bce1
--- /dev/null
+++ b/App/pelagia-portal/app/api/notifications/route.ts
@@ -0,0 +1,29 @@
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ const session = await auth();
+ if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ const [notifications, unreadCount] = await Promise.all([
+ db.notification.findMany({
+ where: { userId: session.user.id },
+ orderBy: { sentAt: "desc" },
+ take: 20,
+ select: {
+ id: true,
+ body: true,
+ link: true,
+ isRead: true,
+ sentAt: true,
+ poId: true,
+ },
+ }),
+ db.notification.count({
+ where: { userId: session.user.id, isRead: false },
+ }),
+ ]);
+
+ return NextResponse.json({ notifications, unreadCount });
+}
diff --git a/App/pelagia-portal/components/layout/header.tsx b/App/pelagia-portal/components/layout/header.tsx
index a40691c..0189acc 100644
--- a/App/pelagia-portal/components/layout/header.tsx
+++ b/App/pelagia-portal/components/layout/header.tsx
@@ -4,6 +4,7 @@ import { signOut } from "next-auth/react";
import { LogOut } from "lucide-react";
import type { Role } from "@prisma/client";
import { CartIcon } from "./cart-icon";
+import { NotificationBell } from "./notification-bell";
const ROLE_LABELS: Record = {
TECHNICAL: "Technical",
@@ -17,16 +18,31 @@ const ROLE_LABELS: Record = {
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];
-interface HeaderProps {
- user: { name: string; email: string; role: Role };
+interface NotificationItem {
+ id: string;
+ body: string;
+ link: string | null;
+ isRead: boolean;
+ sentAt: string;
+ poId: string | null;
}
-export function Header({ user }: HeaderProps) {
+interface HeaderProps {
+ user: { name: string; email: string; role: Role };
+ initialUnreadCount: number;
+ initialNotifications: NotificationItem[];
+}
+
+export function Header({ user, initialUnreadCount, initialNotifications }: HeaderProps) {
return (
{CART_ROLES.includes(user.role) &&
}
+
{user.name}
{ROLE_LABELS[user.role]}
diff --git a/App/pelagia-portal/components/layout/notification-bell.tsx b/App/pelagia-portal/components/layout/notification-bell.tsx
new file mode 100644
index 0000000..75a54f5
--- /dev/null
+++ b/App/pelagia-portal/components/layout/notification-bell.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { Bell } from "lucide-react";
+
+interface NotificationItem {
+ id: string;
+ body: string;
+ link: string | null;
+ isRead: boolean;
+ sentAt: string;
+ poId: string | null;
+}
+
+interface Props {
+ initialUnreadCount: number;
+ initialNotifications: NotificationItem[];
+}
+
+function timeAgo(dateStr: string): string {
+ const diff = Date.now() - new Date(dateStr).getTime();
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ return `${Math.floor(hrs / 24)}d ago`;
+}
+
+export function NotificationBell({ initialUnreadCount, initialNotifications }: Props) {
+ const [open, setOpen] = useState(false);
+ const [unreadCount, setUnreadCount] = useState(initialUnreadCount);
+ const [notifications, setNotifications] = useState
(initialNotifications);
+ const [newArrival, setNewArrival] = useState(false);
+ const prevCountRef = useRef(initialUnreadCount);
+ const panelRef = useRef(null);
+
+ // ── Poll for new notifications every 30 s ───────────────────────────────────
+ const fetchNotifications = useCallback(async () => {
+ try {
+ const res = await fetch("/api/notifications", { cache: "no-store" });
+ if (!res.ok) return;
+ const data: { unreadCount: number; notifications: NotificationItem[] } = await res.json();
+
+ setNotifications(data.notifications);
+
+ if (data.unreadCount > prevCountRef.current) {
+ // New notification arrived — flash the bell
+ setNewArrival(true);
+ setTimeout(() => setNewArrival(false), 3000);
+ }
+ prevCountRef.current = data.unreadCount;
+ setUnreadCount(data.unreadCount);
+ } catch {
+ // network error — silently ignore
+ }
+ }, []);
+
+ useEffect(() => {
+ const id = setInterval(fetchNotifications, 30_000);
+ return () => clearInterval(id);
+ }, [fetchNotifications]);
+
+ // ── Close on outside click ──────────────────────────────────────────────────
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, []);
+
+ // ── Mark all read when panel opens ─────────────────────────────────────────
+ async function handleOpen() {
+ const next = !open;
+ setOpen(next);
+ if (next && unreadCount > 0) {
+ setUnreadCount(0);
+ prevCountRef.current = 0;
+ setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
+ await fetch("/api/notifications/read", { method: "PATCH" });
+ }
+ }
+
+ return (
+
+ {/* Bell button */}
+
+
+ {/* Dropdown panel */}
+ {open && (
+
+ {/* Header */}
+
+
Notifications
+ {notifications.length > 0 && (
+
+ )}
+
+
+ {/* List */}
+
+ {notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+ notifications.map((n) => {
+ const content = (
+
+ {/* Unread dot */}
+
+
+
+ {n.body}
+
+
{timeAgo(n.sentAt)}
+
+
+ );
+
+ return n.link ? (
+
setOpen(false)}
+ className="block"
+ >
+ {content}
+
+ ) : (
+
{content}
+ );
+ })
+ )}
+
+
+ {/* Footer */}
+ {notifications.length === 20 && (
+
+ Showing 20 most recent
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/App/pelagia-portal/lib/notifier.ts b/App/pelagia-portal/lib/notifier.ts
index 1847e13..1b7f0c5 100644
--- a/App/pelagia-portal/lib/notifier.ts
+++ b/App/pelagia-portal/lib/notifier.ts
@@ -31,10 +31,13 @@ export async function notify({ event, po, recipients, note }: NotifyParams) {
await Promise.allSettled(
recipients.map(async (recipient) => {
+ const body = buildInAppBody(event, po, recipient);
+ const link = buildInAppLink(event, po, recipient);
+
let status = "sent";
if (isDev) {
console.log(
- `\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildBody(event, po, note)}\n`
+ `\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n`
);
} else {
try {
@@ -49,13 +52,92 @@ export async function notify({ event, po, recipients, note }: NotifyParams) {
status = "failed";
}
}
+
await db.notification.create({
- data: { subject, body: subject, status, poId: po.id, userId: recipient.id },
+ data: { subject, body, link, status, poId: po.id, userId: recipient.id },
});
})
);
}
+// ── In-app message ────────────────────────────────────────────────────────────
+
+function buildInAppBody(
+ event: NotificationEvent,
+ po: PurchaseOrder & { submitter: User },
+ recipient: User
+): string {
+ const pn = po.poNumber;
+ const submitter = po.submitter.name;
+
+ switch (event) {
+ case "PO_SUBMITTED":
+ // Manager sees who submitted; submitter gets a confirmation
+ return recipient.id === po.submitterId
+ ? `Your PO ${pn} has been submitted for review`
+ : `${submitter} submitted PO ${pn} for your review`;
+
+ case "PO_APPROVED":
+ case "PO_APPROVED_WITH_NOTE":
+ return recipient.id === po.submitterId
+ ? `Your PO ${pn} has been approved`
+ : `PO ${pn} approved — ready for payment`;
+
+ case "PO_REJECTED":
+ return `Your PO ${pn} has been rejected`;
+
+ case "EDITS_REQUESTED":
+ return `Edits requested on your PO ${pn}`;
+
+ case "VENDOR_ID_REQUESTED":
+ return `Vendor ID needed before PO ${pn} can be approved`;
+
+ case "VENDOR_ID_PROVIDED":
+ return `Vendor ID provided for PO ${pn} — ready to review`;
+
+ case "PAYMENT_PROCESSING":
+ return `Payment is being processed for PO ${pn}`;
+
+ case "PAYMENT_SENT":
+ return `Payment confirmed for PO ${pn} — please confirm receipt`;
+
+ case "RECEIPT_CONFIRMED":
+ return `Receipt confirmed — PO ${pn} is now closed`;
+
+ default:
+ return `Update on PO ${pn}`;
+ }
+}
+
+function buildInAppLink(
+ event: NotificationEvent,
+ po: PurchaseOrder & { submitter: User },
+ recipient: User
+): string {
+ switch (event) {
+ case "PO_SUBMITTED":
+ return recipient.id === po.submitterId ? `/po/${po.id}` : `/approvals/${po.id}`;
+ case "PO_APPROVED":
+ case "PO_APPROVED_WITH_NOTE":
+ return recipient.id === po.submitterId ? `/po/${po.id}` : `/payments`;
+ case "PO_REJECTED":
+ case "EDITS_REQUESTED":
+ case "VENDOR_ID_REQUESTED":
+ return `/po/${po.id}`;
+ case "VENDOR_ID_PROVIDED":
+ return `/approvals/${po.id}`;
+ case "PAYMENT_PROCESSING":
+ case "RECEIPT_CONFIRMED":
+ return `/po/${po.id}`;
+ case "PAYMENT_SENT":
+ return `/po/${po.id}/receipt`;
+ default:
+ return `/po/${po.id}`;
+ }
+}
+
+// ── Email subject ─────────────────────────────────────────────────────────────
+
function buildSubject(event: NotificationEvent, poNumber: string): string | null {
const base = `PO ${poNumber}`;
const map: Record = {
@@ -73,6 +155,8 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
return map[event] ?? null;
}
+// ── Email HTML ────────────────────────────────────────────────────────────────
+
function buildHtml(
event: NotificationEvent,
po: PurchaseOrder & { submitter: User },
@@ -87,7 +171,7 @@ function buildHtml(
Pelagia Portal
Hi ${recipient.name},
-
${buildBody(event, po, note)}
+
${buildEmailBody(event, po, note)}
PO Number: ${po.poNumber}
Title: ${po.title}
@@ -100,7 +184,7 @@ function buildHtml(