From d0f04f13f8dc14b13052279ffc08470eae994fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 18 Mar 2026 11:51:49 +0100 Subject: [PATCH] feat: enterprise notification & task management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/(app)/admin/notifications/page.tsx | 5 + apps/web/src/app/(app)/notifications/page.tsx | 10 + apps/web/src/app/api/sse/timeline/route.ts | 4 + .../dashboard/widgets/TaskWidget.tsx | 117 ++++ apps/web/src/components/layout/AppShell.tsx | 8 + .../BroadcastManagementClient.tsx | 144 +++++ .../notifications/BroadcastModal.tsx | 328 ++++++++++ .../notifications/NotificationBell.tsx | 259 ++++++-- .../NotificationCenterClient.tsx | 394 ++++++++++++ .../notifications/ReminderModal.tsx | 305 ++++++++++ .../src/components/notifications/TaskCard.tsx | 185 ++++++ apps/web/src/hooks/useTimelineSSE.ts | 15 + packages/api/package.json | 3 +- .../api/src/lib/notification-targeting.ts | 71 +++ packages/api/src/lib/reminder-scheduler.ts | 98 +++ packages/api/src/lib/task-actions.ts | 77 +++ packages/api/src/router/allocation.ts | 44 +- packages/api/src/router/assistant-tools.ts | 516 ++++++++++++++++ packages/api/src/router/assistant.ts | 9 +- packages/api/src/router/notification.ts | 567 +++++++++++++++++- packages/api/src/router/vacation.ts | 105 +++- packages/api/src/sse/event-bus.ts | 20 + packages/db/prisma/schema.prisma | 87 ++- packages/shared/src/constants/index.ts | 5 + packages/shared/src/types/index.ts | 2 +- packages/shared/src/types/notification.ts | 80 +++ 26 files changed, 3404 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/app/(app)/admin/notifications/page.tsx create mode 100644 apps/web/src/app/(app)/notifications/page.tsx create mode 100644 apps/web/src/components/dashboard/widgets/TaskWidget.tsx create mode 100644 apps/web/src/components/notifications/BroadcastManagementClient.tsx create mode 100644 apps/web/src/components/notifications/BroadcastModal.tsx create mode 100644 apps/web/src/components/notifications/NotificationCenterClient.tsx create mode 100644 apps/web/src/components/notifications/ReminderModal.tsx create mode 100644 apps/web/src/components/notifications/TaskCard.tsx create mode 100644 packages/api/src/lib/notification-targeting.ts create mode 100644 packages/api/src/lib/reminder-scheduler.ts create mode 100644 packages/api/src/lib/task-actions.ts diff --git a/apps/web/src/app/(app)/admin/notifications/page.tsx b/apps/web/src/app/(app)/admin/notifications/page.tsx new file mode 100644 index 0000000..b17ec22 --- /dev/null +++ b/apps/web/src/app/(app)/admin/notifications/page.tsx @@ -0,0 +1,5 @@ +import { BroadcastManagementClient } from "~/components/notifications/BroadcastManagementClient.js"; + +export default function AdminNotificationsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/notifications/page.tsx b/apps/web/src/app/(app)/notifications/page.tsx new file mode 100644 index 0000000..ef11f83 --- /dev/null +++ b/apps/web/src/app/(app)/notifications/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { NotificationCenterClient } from "~/components/notifications/NotificationCenterClient.js"; + +export default function NotificationsPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/api/sse/timeline/route.ts b/apps/web/src/app/api/sse/timeline/route.ts index 529f918..2f18dd4 100644 --- a/apps/web/src/app/api/sse/timeline/route.ts +++ b/apps/web/src/app/api/sse/timeline/route.ts @@ -1,7 +1,11 @@ import { eventBus } from "@planarchy/api/sse"; +import { startReminderScheduler } from "@planarchy/api/lib/reminder-scheduler"; import { SSE_EVENT_TYPES } from "@planarchy/shared"; import { auth } from "~/server/auth.js"; +// Start the reminder scheduler (idempotent — only starts once) +startReminderScheduler(); + export const dynamic = "force-dynamic"; export const runtime = "nodejs"; diff --git a/apps/web/src/components/dashboard/widgets/TaskWidget.tsx b/apps/web/src/components/dashboard/widgets/TaskWidget.tsx new file mode 100644 index 0000000..1a871c4 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/TaskWidget.tsx @@ -0,0 +1,117 @@ +"use client"; + +import Link from "next/link"; +import type { Route } from "next"; +import { trpc } from "~/lib/trpc/client.js"; +import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; +import { TaskCard } from "~/components/notifications/TaskCard.js"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function TaskWidget(_props: Partial = {}) { + const utils = trpc.useUtils(); + + const { data: tasks, isLoading: loadingTasks } = trpc.notification.listTasks.useQuery( + { status: "OPEN", limit: 5 }, + { staleTime: 30_000, placeholderData: (prev) => prev }, + ); + + const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(undefined, { + staleTime: 30_000, + placeholderData: (prev) => prev, + }); + + 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(); + }, + }); + + function handleStatusChange(id: string, status: string) { + updateTaskStatus.mutate({ id, status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" }); + } + + const openCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0); + const isLoading = loadingTasks || loadingCounts; + + if (isLoading) { + return ( +
+
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ Open Tasks + {openCount > 0 && ( + + {openCount} + + )} +

+ {taskCounts?.overdue !== undefined && taskCounts.overdue > 0 && ( + + {taskCounts.overdue} overdue + + )} +
+ + {/* Content */} +
+ {!tasks || tasks.length === 0 ? ( +
+ No open tasks +
+ ) : ( + tasks.map((t) => ( + + )) + )} +
+ + {/* Footer */} +
+ + View all → + +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index e695771..46e21ce 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -57,6 +57,12 @@ function ChargeabilityIcon() { function GraphIcon() { return ; } +function NotificationsIcon() { + return ; +} +function BroadcastIcon() { + return ; +} function AdminIcon() { return ; } @@ -72,6 +78,7 @@ const navSections: NavSection[] = [ { href: "/timeline", label: "Timeline", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, { href: "/allocations", label: "Allocations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/staffing", label: "Staffing", icon: , roles: ["ADMIN", "MANAGER"] }, + { href: "/notifications", label: "Notifications", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, ], }, { @@ -134,6 +141,7 @@ const adminNavEntries: AdminEntry[] = [ { href: "/admin/users", label: "Users", icon: }, { href: "/admin/settings", label: "Settings", icon: }, { href: "/admin/skill-import", label: "Skill Import", icon: }, + { href: "/admin/notifications", label: "Broadcasts", icon: }, ]; /** diff --git a/apps/web/src/components/notifications/BroadcastManagementClient.tsx b/apps/web/src/components/notifications/BroadcastManagementClient.tsx new file mode 100644 index 0000000..e3de534 --- /dev/null +++ b/apps/web/src/components/notifications/BroadcastManagementClient.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "~/lib/trpc/client.js"; +import { BroadcastModal } from "./BroadcastModal.js"; + +function formatDate(date: string | Date): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +const TARGET_LABELS: Record = { + all: "All Users", + role: "By Role", + project: "By Project", + orgUnit: "By Org Unit", + user: "Specific User", +}; + +export function BroadcastManagementClient() { + const [showModal, setShowModal] = useState(false); + + const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery( + { limit: 50 }, + { staleTime: 30_000 }, + ); + + const utils = trpc.useUtils(); + + function handleSuccess() { + void utils.notification.listBroadcasts.invalidate(); + } + + return ( +
+ {/* Header */} +
+

Broadcast Management

+ +
+ + {/* Loading */} + {isLoading && ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+ )} + + {/* Table */} + {!isLoading && ( + <> + {broadcasts.length === 0 ? ( +
+ No broadcasts sent yet. +
+ ) : ( +
+ + + + + + + + + + + + {broadcasts.map((b) => ( + + + + + + + + ))} + +
+ Title + + Target + + Recipients + + Sender + + Date +
+

+ {b.title} +

+ {b.body && ( +

+ {b.body} +

+ )} +
+ {TARGET_LABELS[b.targetType] ?? b.targetType} + {b.targetValue && ( + ({b.targetValue}) + )} + + {b.recipientCount ?? 0} + + {b.sender?.name ?? b.sender?.email ?? "-"} + + {b.sentAt ? formatDate(b.sentAt) : "Scheduled"} +
+
+ )} + + )} + + {/* Broadcast Modal */} + {showModal && ( + setShowModal(false)} + onSuccess={handleSuccess} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/notifications/BroadcastModal.tsx b/apps/web/src/components/notifications/BroadcastModal.tsx new file mode 100644 index 0000000..bc985b0 --- /dev/null +++ b/apps/web/src/components/notifications/BroadcastModal.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useFocusTrap } from "~/hooks/useFocusTrap.js"; +import { trpc } from "~/lib/trpc/client.js"; + +const TARGET_TYPES = [ + { value: "all", label: "All Users" }, + { value: "role", label: "By Role" }, + { value: "project", label: "By Project" }, + { value: "orgUnit", label: "By Org Unit" }, +] as const; + +const ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const; + +const PRIORITY_OPTIONS = [ + { value: "LOW", label: "Low" }, + { value: "NORMAL", label: "Normal" }, + { value: "HIGH", label: "High" }, + { value: "URGENT", label: "Urgent" }, +] as const; + +const CHANNEL_OPTIONS = [ + { value: "in_app", label: "In-App" }, + { value: "email", label: "Email" }, + { value: "both", label: "Both" }, +] as const; + +interface BroadcastModalProps { + onClose: () => void; + onSuccess: () => void; +} + +export function BroadcastModal({ onClose, onSuccess }: BroadcastModalProps) { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [targetType, setTargetType] = useState("all"); + const [targetValue, setTargetValue] = useState(""); + const [priority, setPriority] = useState("NORMAL"); + const [channel, setChannel] = useState("in_app"); + const [link, setLink] = useState(""); + const [serverError, setServerError] = useState(null); + const [result, setResult] = useState<{ recipientCount: number } | null>(null); + + const panelRef = useRef(null); + useFocusTrap(panelRef, true); + + const utils = trpc.useUtils(); + + const createMutation = trpc.notification.createBroadcast.useMutation({ + onSuccess: async (data) => { + await utils.notification.listBroadcasts.invalidate(); + const count = (data as { recipientCount?: number }).recipientCount ?? 0; + setResult({ recipientCount: count }); + }, + onError: (err) => setServerError(err.message), + }); + + const isPending = createMutation.isPending; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setServerError(null); + + if (!title.trim()) { + setServerError("Title is required."); + return; + } + + createMutation.mutate({ + title: title.trim(), + ...(body.trim() ? { body: body.trim() } : {}), + targetType: targetType as "all" | "role" | "project" | "orgUnit" | "user", + ...(targetType !== "all" && targetValue.trim() ? { targetValue: targetValue.trim() } : {}), + priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT", + channel: channel as "in_app" | "email" | "both", + ...(link.trim() ? { link: link.trim() } : {}), + }); + } + + const inputClass = + "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"; + const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; + + // After successful send, show result + if (result) { + return ( +
{ + if (e.target === e.currentTarget) { onSuccess(); onClose(); } + }} + > +
{ if (e.key === "Escape") { onSuccess(); onClose(); } }} + > +
+
+ + + +
+

Broadcast Sent

+

+ Sent to {result.recipientCount} recipient{result.recipientCount !== 1 ? "s" : ""} +

+ +
+
+
+ ); + } + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
{ if (e.key === "Escape") onClose(); }} + > + {/* Header */} +
+

Send Broadcast

+ +
+ +
+ {/* Title */} +
+ + setTitle(e.target.value)} + maxLength={200} + className={inputClass} + required + placeholder="Broadcast title..." + /> +
+ + {/* Body */} +
+ +