feat: enterprise notification & task management system

Phase N.1 — Data Model:
- Extend Notification model with category, priority, task fields (status, action,
  assignee, dueDate, completedAt/By), reminder fields (remindAt, recurrence,
  nextRemindAt), and targeting metadata (sourceId, senderId, channel)
- Add NotificationCategory, NotificationPriority, TaskStatus enums
- Add NotificationBroadcast model for group notifications
- Shared types with parseTaskAction()/buildTaskAction() helpers

Phase N.2 — API:
- Extend notification router: listTasks, taskCounts, updateTaskStatus,
  createReminder/update/delete/list, createBroadcast/listBroadcasts,
  createTask, assignTask, delete
- Broadcast targeting: resolve recipients by user/role/project/orgUnit/all
- Task-action registry: approve_vacation, reject_vacation, confirm_assignment
- Reminder scheduler: 60s poll interval, recurring support, catch-up on start
- SSE events: TASK_ASSIGNED, TASK_COMPLETED, TASK_STATUS_CHANGED,
  REMINDER_DUE, BROADCAST_SENT

Phase N.3 — AI Assistant:
- 7 new tools: list_tasks, get_task_detail, update_task_status,
  execute_task_action, create_reminder, create_task_for_user, send_broadcast
- execute_task_action dispatches to task-action registry with per-action
  permission checks, marks tasks as completed by AI

Phase N.4 — Frontend:
- Enhanced NotificationBell with task badge, tabs (All/Tasks/Reminders)
- TaskCard component with priority badges, due dates, action buttons
- ReminderModal for creating/editing personal reminders
- BroadcastModal for targeted group notifications (manager+)
- NotificationCenter full-page with 5 tabs and bulk actions
- TaskWidget dashboard widget showing open tasks
- Admin broadcast management page
- AppShell nav links for Notifications and Broadcasts
- SSE hook handlers for task/reminder events

Phase N.5 — Auto-Tasks:
- Vacation create → APPROVAL tasks for all managers
- Vacation approve/reject → mark approval tasks as DONE
- Demand create → TASK for managers to fill staffing needs

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 11:51:49 +01:00
parent 093e13b88f
commit d0f04f13f8
26 changed files with 3404 additions and 54 deletions
@@ -2,6 +2,8 @@
import { useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
@@ -19,8 +21,11 @@ function relativeTime(date: Date): string {
return `${months}mo ago`;
}
type TabKey = "all" | "tasks" | "reminders";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const ref = useRef<HTMLDivElement>(null);
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
@@ -33,9 +38,27 @@ export function NotificationBell() {
retry: false,
});
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30_000,
retry: false,
});
const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
const { data: notifications = [] } = trpc.notification.list.useQuery(
{ limit: 20 },
{ enabled: open && isAuthenticated, retry: false },
{ enabled: open && isAuthenticated && activeTab === "all", retry: false },
);
const { data: tasks = [] } = trpc.notification.listTasks.useQuery(
{ status: "OPEN", limit: 10 },
{ enabled: open && isAuthenticated && activeTab === "tasks", retry: false },
);
const { data: reminders = [] } = trpc.notification.listReminders.useQuery(
{ limit: 10 },
{ enabled: open && isAuthenticated && activeTab === "reminders", retry: false },
);
const markRead = trpc.notification.markRead.useMutation({
@@ -45,6 +68,15 @@ export function NotificationBell() {
},
});
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
onSuccess: () => {
void utils.notification.taskCounts.invalidate();
void utils.notification.listTasks.invalidate();
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
@@ -67,6 +99,16 @@ export function NotificationBell() {
markRead.mutate({ id });
}
function handleTaskAction(id: string, taskStatus: string) {
updateTaskStatus.mutate({ id, status: taskStatus as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
}
const tabs: { key: TabKey; label: string }[] = [
{ key: "all", label: "All" },
{ key: "tasks", label: "Tasks" },
{ key: "reminders", label: "Reminders" },
];
return (
<div ref={ref} className="relative">
{/* Bell button */}
@@ -90,22 +132,29 @@ export function NotificationBell() {
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{/* Unread notification badge (red) */}
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
<span className="absolute top-0.5 right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{/* Task count badge (orange) */}
{openTaskCount > 0 && (
<span className="absolute -bottom-0.5 right-0.5 flex items-center justify-center min-w-[14px] h-3.5 px-0.5 text-[9px] font-bold text-white bg-orange-500 rounded-full leading-none">
{openTaskCount > 99 ? "99+" : openTaskCount}
</span>
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
Notifications
</span>
{unreadCount > 0 && (
{unreadCount > 0 && activeTab === "all" && (
<button
type="button"
onClick={handleMarkAllRead}
@@ -117,48 +166,174 @@ export function NotificationBell() {
)}
</div>
{/* List */}
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No notifications yet
</div>
) : (
notifications.map((n) => {
const isUnread = n.readAt === null;
return (
<button
key={n.id}
type="button"
onClick={() => {
if (isUnread) handleMarkOne(n.id);
}}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
}`}
>
<div className="flex items-start gap-2">
{isUnread && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
<div className={isUnread ? "" : "ml-4"}>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{n.body}
{/* Tabs */}
<div className="flex border-b border-gray-100 dark:border-gray-800">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${
activeTab === tab.key
? "text-brand-600 dark:text-brand-400 border-b-2 border-brand-600 dark:border-brand-400"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
{tab.label}
{tab.key === "tasks" && openTaskCount > 0 && (
<span className="ml-1 inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-orange-500 rounded-full">
{openTaskCount}
</span>
)}
</button>
))}
</div>
{/* Content */}
<div className="max-h-80 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
{activeTab === "all" && (
<>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No notifications yet
</div>
) : (
notifications.map((n) => {
const isUnread = n.readAt === null;
return (
<button
key={n.id}
type="button"
onClick={() => {
if (isUnread) handleMarkOne(n.id);
}}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
}`}
>
<div className="flex items-start gap-2">
{isUnread && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
<div className={isUnread ? "" : "ml-4"}>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{n.body}
</p>
)}
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{relativeTime(new Date(n.createdAt))}
</p>
</div>
</div>
</button>
);
})
)}
</>
)}
{activeTab === "tasks" && (
<>
{tasks.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No open tasks
</div>
) : (
tasks.map((t) => (
<div key={t.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug truncate">
{t.title}
</p>
)}
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{relativeTime(new Date(n.createdAt))}
</p>
{t.dueDate && (
<p className={`mt-0.5 text-xs ${new Date(t.dueDate).getTime() < Date.now() ? "text-red-600 dark:text-red-400 font-semibold" : "text-gray-400 dark:text-gray-500"}`}>
Due: {new Date(t.dueDate).toLocaleDateString("en-GB")}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => handleTaskAction(t.id, "DONE")}
disabled={updateTaskStatus.isPending}
className="p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded transition-colors"
title="Mark as Done"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
onClick={() => handleTaskAction(t.id, "DISMISSED")}
disabled={updateTaskStatus.isPending}
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Dismiss"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</button>
);
})
))
)}
</>
)}
{activeTab === "reminders" && (
<>
{reminders.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No reminders
</div>
) : (
reminders.map((r) => (
<div key={r.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
{r.title}
</p>
{r.body && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
{r.body}
</p>
)}
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{r.nextRemindAt
? new Date(r.nextRemindAt).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: relativeTime(new Date(r.createdAt))}
{r.recurrence && (
<span className="ml-2 text-brand-500 dark:text-brand-400">{r.recurrence}</span>
)}
</p>
</div>
))
)}
</>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 dark:border-gray-800 px-4 py-2">
<Link
href={"/notifications" as Route}
onClick={() => setOpen(false)}
className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
>
View all &rarr;
</Link>
</div>
</div>
)}