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:
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { TaskCard } from "./TaskCard.js";
|
||||
import { ReminderModal } from "./ReminderModal.js";
|
||||
|
||||
type TabKey = "all" | "notifications" | "tasks" | "reminders" | "approvals";
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
export function NotificationCenterClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialTab = (searchParams.get("tab") as TabKey) || "all";
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||
const [reminderModal, setReminderModal] = useState<{
|
||||
open: boolean;
|
||||
reminder: {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
remindAt?: string | Date | null;
|
||||
recurrence?: string | null;
|
||||
link?: string | null;
|
||||
} | null;
|
||||
}>({ open: false, reminder: null });
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Queries
|
||||
const { data: allNotifications = [], isLoading: loadingAll } = trpc.notification.list.useQuery(
|
||||
{ limit: 50 },
|
||||
{ enabled: activeTab === "all", refetchInterval: 30_000 },
|
||||
);
|
||||
|
||||
const { data: notifications = [], isLoading: loadingNotifications } = trpc.notification.list.useQuery(
|
||||
{ category: "NOTIFICATION", limit: 50 },
|
||||
{ enabled: activeTab === "notifications", refetchInterval: 30_000 },
|
||||
);
|
||||
|
||||
const { data: tasks = [], isLoading: loadingTasks } = trpc.notification.listTasks.useQuery(
|
||||
{ limit: 50 },
|
||||
{ enabled: activeTab === "tasks", refetchInterval: 30_000 },
|
||||
);
|
||||
|
||||
const { data: reminders = [], isLoading: loadingReminders } = trpc.notification.listReminders.useQuery(
|
||||
{ limit: 50 },
|
||||
{ enabled: activeTab === "reminders", refetchInterval: 30_000 },
|
||||
);
|
||||
|
||||
const { data: approvals = [], isLoading: loadingApprovals } = trpc.notification.list.useQuery(
|
||||
{ category: "APPROVAL", limit: 50 },
|
||||
{ enabled: activeTab === "approvals", refetchInterval: 30_000 },
|
||||
);
|
||||
|
||||
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const markRead = trpc.notification.markRead.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notification.unreadCount.invalidate();
|
||||
void utils.notification.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notification.taskCounts.invalidate();
|
||||
void utils.notification.listTasks.invalidate();
|
||||
void utils.notification.list.invalidate();
|
||||
void utils.notification.unreadCount.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteReminder = trpc.notification.deleteReminder.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notification.listReminders.invalidate();
|
||||
void utils.notification.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
function handleTaskStatusChange(id: string, status: string) {
|
||||
updateTaskStatus.mutate({ id, status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
||||
}
|
||||
|
||||
function handleMarkAllRead() {
|
||||
markRead.mutate({});
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string; count?: number }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "notifications", label: "Notifications" },
|
||||
{ key: "tasks", label: "Tasks", count: (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0) },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
{ key: "approvals", label: "Approvals" },
|
||||
];
|
||||
|
||||
const isLoading =
|
||||
(activeTab === "all" && loadingAll) ||
|
||||
(activeTab === "notifications" && loadingNotifications) ||
|
||||
(activeTab === "tasks" && loadingTasks) ||
|
||||
(activeTab === "reminders" && loadingReminders) ||
|
||||
(activeTab === "approvals" && loadingApprovals);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Notification Center</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{activeTab === "reminders" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReminderModal({ open: true, reminder: null })}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New Reminder
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markRead.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2.5 text-sm 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.count !== undefined && tab.count > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-5 px-1.5 text-[10px] font-bold text-white bg-orange-500 rounded-full">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="mt-2 h-3 w-1/2 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!isLoading && (
|
||||
<div className="space-y-3">
|
||||
{/* All / Notifications / Approvals tabs */}
|
||||
{(activeTab === "all" || activeTab === "notifications" || activeTab === "approvals") && (
|
||||
<>
|
||||
{(activeTab === "all" ? allNotifications : activeTab === "notifications" ? notifications : approvals).length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{activeTab === "approvals" ? "No pending approvals" : "No notifications"}
|
||||
</div>
|
||||
) : (
|
||||
(activeTab === "all" ? allNotifications : activeTab === "notifications" ? notifications : approvals).map((n) => {
|
||||
const isUnread = n.readAt === null;
|
||||
const isTask = n.category === "TASK" || n.category === "APPROVAL";
|
||||
|
||||
if (isTask && n.taskStatus) {
|
||||
return (
|
||||
<TaskCard
|
||||
key={n.id}
|
||||
task={{
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
priority: n.priority ?? "NORMAL",
|
||||
taskStatus: n.taskStatus,
|
||||
taskAction: n.taskAction,
|
||||
dueDate: n.dueDate,
|
||||
entityType: n.entityType,
|
||||
link: n.link,
|
||||
createdAt: n.createdAt,
|
||||
completedBy: n.completedBy,
|
||||
}}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isUnread) markRead.mutate({ id: n.id });
|
||||
}}
|
||||
className={`w-full text-left rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : "bg-white dark:bg-gray-900/70"
|
||||
}`}
|
||||
>
|
||||
<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 ? "flex-1" : "flex-1 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>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>{relativeTime(new Date(n.createdAt))}</span>
|
||||
{n.category && n.category !== "NOTIFICATION" && (
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase">
|
||||
{n.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tasks tab */}
|
||||
{activeTab === "tasks" && (
|
||||
<>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No tasks
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((t) => (
|
||||
<TaskCard
|
||||
key={t.id}
|
||||
task={{
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
body: t.body,
|
||||
priority: t.priority ?? "NORMAL",
|
||||
taskStatus: t.taskStatus,
|
||||
taskAction: t.taskAction,
|
||||
dueDate: t.dueDate,
|
||||
entityType: t.entityType,
|
||||
link: t.link,
|
||||
createdAt: t.createdAt,
|
||||
completedBy: t.completedBy,
|
||||
}}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reminders tab */}
|
||||
{activeTab === "reminders" && (
|
||||
<>
|
||||
{reminders.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No reminders. Create one to get started.
|
||||
</div>
|
||||
) : (
|
||||
reminders.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/70 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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-2">
|
||||
{r.body}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
{r.nextRemindAt && (
|
||||
<span>
|
||||
{new Date(r.nextRemindAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{r.recurrence && (
|
||||
<span className="rounded-full bg-brand-100 dark:bg-brand-900/30 px-2 py-0.5 text-[10px] font-medium text-brand-700 dark:text-brand-300 uppercase">
|
||||
{r.recurrence}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setReminderModal({
|
||||
open: true,
|
||||
reminder: {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
body: r.body,
|
||||
remindAt: r.nextRemindAt ?? r.remindAt,
|
||||
recurrence: r.recurrence,
|
||||
link: r.link,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this reminder?")) {
|
||||
deleteReminder.mutate({ id: r.id });
|
||||
}
|
||||
}}
|
||||
disabled={deleteReminder.isPending}
|
||||
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reminder Modal */}
|
||||
{reminderModal.open && (
|
||||
<ReminderModal
|
||||
reminder={reminderModal.reminder}
|
||||
onClose={() => setReminderModal({ open: false, reminder: null })}
|
||||
onSuccess={() => setReminderModal({ open: false, reminder: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user