mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
feat: real notifications in the sidebar bell
Auto-generate notifications on patient record create/update (fan out to the other clinic members, pushed live), and wire the sidebar bell to real data via a useNotifications hook over the shared socket: live unread badge, real list, mark-all-read on open. Drops the hardcoded sample array and the dead "View all" link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,41 @@ import {
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { emitToUser } from "../realtime.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { listClinicMembers } from "../services/messaging.js";
|
||||
import { createNotification } from "../services/notifications.js";
|
||||
import * as service from "../services/patients.js";
|
||||
|
||||
export const patientsRouter = Router();
|
||||
|
||||
// Notify the rest of the clinic about a patient record change (best-effort,
|
||||
// pushed live over the socket).
|
||||
async function notifyClinic(
|
||||
orgId: string,
|
||||
actor: { id: string; name: string },
|
||||
text: string,
|
||||
fileNumber: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const members = await listClinicMembers(orgId, actor.id);
|
||||
for (const m of members) {
|
||||
const notification = await createNotification({
|
||||
orgId,
|
||||
userId: m.id,
|
||||
type: "record_change",
|
||||
text,
|
||||
entityType: "patient",
|
||||
entityId: fileNumber,
|
||||
actorName: actor.name,
|
||||
});
|
||||
if (notification) emitToUser(m.id, "notification:new", notification);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to notify clinic:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Every patient route requires a signed-in user with an active clinic.
|
||||
patientsRouter.use(requireAuth, requireOrg);
|
||||
|
||||
@@ -64,6 +94,12 @@ patientsRouter.post(
|
||||
patientName: created.name,
|
||||
patientFileNumber: created.fileNumber,
|
||||
});
|
||||
await notifyClinic(
|
||||
req.organizationId!,
|
||||
{ id: req.user!.id, name: req.user!.name },
|
||||
`${req.user!.name} added patient ${created.name}`,
|
||||
created.fileNumber,
|
||||
);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -92,6 +128,12 @@ patientsRouter.put(
|
||||
patientName: updated.name,
|
||||
patientFileNumber: updated.fileNumber,
|
||||
});
|
||||
await notifyClinic(
|
||||
req.organizationId!,
|
||||
{ id: req.user!.id, name: req.user!.name },
|
||||
`${req.user!.name} updated ${updated.name}'s record`,
|
||||
updated.fileNumber,
|
||||
);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -23,30 +23,6 @@ import DashboardNavigation from "@/components/sidebar-02/nav-main";
|
||||
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
|
||||
import { NavUser } from "@/components/sidebar-02/nav-user";
|
||||
|
||||
const sampleNotifications = [
|
||||
{
|
||||
id: "1",
|
||||
avatar: "/avatars/01.png",
|
||||
fallback: "LR",
|
||||
text: "New lab results are available.",
|
||||
time: "10m ago",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
avatar: "/avatars/02.png",
|
||||
fallback: "PC",
|
||||
text: "A patient chart was updated.",
|
||||
time: "1h ago",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
avatar: "/avatars/03.png",
|
||||
fallback: "CT",
|
||||
text: "New message from the care team.",
|
||||
time: "2h ago",
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
@@ -106,7 +82,7 @@ export function DashboardSidebar() {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<NotificationsPopover notifications={sampleNotifications} />
|
||||
<NotificationsPopover />
|
||||
<SidebarTrigger />
|
||||
</motion.div>
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Menu,
|
||||
@@ -11,47 +14,71 @@ import {
|
||||
MenuSeparator,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNotifications } from "@/lib/use-notifications";
|
||||
|
||||
type Notification = {
|
||||
id: string;
|
||||
avatar: string;
|
||||
fallback: string;
|
||||
text: string;
|
||||
time: string;
|
||||
};
|
||||
// ISO timestamp -> "just now" / "10m ago" / "3h ago" / "2d ago".
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function NotificationsPopover({
|
||||
notifications,
|
||||
}: {
|
||||
notifications: Notification[];
|
||||
}) {
|
||||
export function NotificationsPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { items, unread, markAllRead } = useNotifications();
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuTrigger render={<Button variant="ghost" size="icon" className="rounded-full" aria-label="Open notifications" />}><BellIcon className="size-5" /></MenuTrigger>
|
||||
<MenuPopup side="right" className="w-80 my-6">
|
||||
<Menu
|
||||
onOpenChange={(open) => {
|
||||
// Viewing the list clears the unread badge.
|
||||
if (open && unread > 0) markAllRead();
|
||||
}}
|
||||
>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("nav.notifications")}
|
||||
className="relative rounded-full"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BellIcon className="size-5" />
|
||||
{unread > 0 && (
|
||||
<span className="-top-0.5 -right-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuPopup side="right" className="my-6 w-80">
|
||||
<MenuGroup>
|
||||
<MenuGroupLabel>{t("nav.notifications")}</MenuGroupLabel>
|
||||
</MenuGroup>
|
||||
<MenuSeparator />
|
||||
{notifications.map(({ id, avatar, fallback, text, time }) => (
|
||||
<MenuItem key={id} className="flex items-start gap-3">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={avatar} alt="Avatar" />
|
||||
<AvatarFallback>{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{text}</span>
|
||||
<span className="text-xs text-muted-foreground">{time}</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
|
||||
{t("nav.viewAllNotifications")}
|
||||
</MenuItem>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
|
||||
{t("nav.notificationsEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
items.map((n) => (
|
||||
<MenuItem className="flex items-start gap-3" key={n.id}>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{n.actorInitials ?? "•"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="font-medium text-sm">{n.text}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{relativeTime(n.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"activity": "Activity",
|
||||
"settings": "Settings",
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"notificationsEmpty": "You're all caught up.",
|
||||
"quickNav": "Quick nav",
|
||||
"commandGroup": "Go to",
|
||||
"commandPlaceholder": "Search pages…",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
// A notification for the signed-in user. Mirrors the backend
|
||||
// `src/types/notification.ts`.
|
||||
export type Notification = {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
read: boolean;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
actorName: string | null;
|
||||
actorInitials: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type NotificationsResponse = {
|
||||
notifications: Notification[];
|
||||
unread: number;
|
||||
};
|
||||
|
||||
export function listNotifications(): Promise<NotificationsResponse> {
|
||||
return apiFetch<NotificationsResponse>("/api/notifications");
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/notifications/${id}/read`, { method: "PATCH" });
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead(): Promise<void> {
|
||||
return apiFetch<void>("/api/notifications/read-all", { method: "POST" });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
type Notification,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
} from "@/lib/notifications";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
|
||||
// Loads the signed-in user's notifications and keeps them live via the shared
|
||||
// socket ("notification:new"). Exposes a markAllRead action for the popover.
|
||||
export function useNotifications() {
|
||||
const [items, setItems] = useState<Notification[]>([]);
|
||||
const [unread, setUnread] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
listNotifications()
|
||||
.then((res) => {
|
||||
if (active) {
|
||||
setItems(res.notifications);
|
||||
setUnread(res.unread);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* api-client redirects on 401 */
|
||||
});
|
||||
|
||||
const socket = getSocket();
|
||||
const onNew = (n: Notification) => {
|
||||
setItems((prev) => [n, ...prev].slice(0, 30));
|
||||
setUnread((u) => u + 1);
|
||||
};
|
||||
socket.on("notification:new", onNew);
|
||||
return () => {
|
||||
active = false;
|
||||
socket.off("notification:new", onNew);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markAllRead = async () => {
|
||||
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
setUnread(0);
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
return { items, unread, markAllRead };
|
||||
}
|
||||
Reference in New Issue
Block a user