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
@@ -0,0 +1,305 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
const RECURRENCE_OPTIONS = [
{ value: "", label: "None" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
] as const;
interface ReminderModalProps {
reminder?: {
id: string;
title: string;
body?: string | null;
remindAt?: string | Date | null;
recurrence?: string | null;
link?: string | null;
} | null;
onClose: () => void;
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? "";
}
function toTimeInputValue(date: Date | string | null | undefined): string {
if (!date) return "09:00";
const d = typeof date === "string" ? new Date(date) : date;
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalProps) {
const isEdit = !!reminder;
const [title, setTitle] = useState(reminder?.title ?? "");
const [body, setBody] = useState(reminder?.body ?? "");
const [remindDate, setRemindDate] = useState(toDateInputValue(reminder?.remindAt));
const [remindTime, setRemindTime] = useState(toTimeInputValue(reminder?.remindAt));
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
const [link, setLink] = useState(reminder?.link ?? "");
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const createMutation = trpc.notification.createReminder.useMutation({
onSuccess: async () => {
await utils.notification.listReminders.invalidate();
await utils.notification.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const updateMutation = trpc.notification.updateReminder.useMutation({
onSuccess: async () => {
await utils.notification.listReminders.invalidate();
await utils.notification.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const deleteMutation = trpc.notification.deleteReminder.useMutation({
onSuccess: async () => {
await utils.notification.listReminders.invalidate();
await utils.notification.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const isPending = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
function buildRemindAt(): Date | null {
if (!remindDate) return null;
const [hours, minutes] = remindTime.split(":").map(Number);
const d = new Date(remindDate + "T00:00:00");
d.setHours(hours ?? 9, minutes ?? 0, 0, 0);
return d;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
const remindAt = buildRemindAt();
if (!title.trim()) {
setServerError("Title is required.");
return;
}
if (!remindAt) {
setServerError("Remind date is required.");
return;
}
if (isEdit && reminder) {
updateMutation.mutate({
id: reminder.id,
title: title.trim(),
body: body.trim() || undefined,
remindAt,
recurrence: (recurrence || null) as "daily" | "weekly" | "monthly" | null,
});
} else {
createMutation.mutate({
title: title.trim(),
body: body.trim() || undefined,
remindAt,
...(recurrence ? { recurrence: recurrence as "daily" | "weekly" | "monthly" } : {}),
...(link.trim() ? { link: link.trim() } : {}),
});
}
}
function handleDelete() {
if (!reminder) return;
if (!window.confirm("Delete this reminder?")) return;
deleteMutation.mutate({ id: reminder.id });
}
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";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEdit ? "Edit Reminder" : "New Reminder"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Title */}
<div>
<label htmlFor="rem-title" className={labelClass}>
Title <span className="text-red-500">*</span>
</label>
<input
id="rem-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className={inputClass}
required
placeholder="Reminder title..."
/>
</div>
{/* Body */}
<div>
<label htmlFor="rem-body" className={labelClass}>
Description (optional)
</label>
<textarea
id="rem-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
maxLength={2000}
className={`${inputClass} resize-none`}
placeholder="Additional details..."
/>
</div>
{/* Date + Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="rem-date" className={labelClass}>
Remind Date <span className="text-red-500">*</span>
</label>
<DateInput
id="rem-date"
value={remindDate}
onChange={setRemindDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="rem-time" className={labelClass}>
Remind Time <span className="text-red-500">*</span>
</label>
<input
id="rem-time"
type="time"
value={remindTime}
onChange={(e) => setRemindTime(e.target.value)}
className={inputClass}
required
/>
</div>
</div>
{/* Recurrence */}
<div>
<label htmlFor="rem-recurrence" className={labelClass}>
Recurrence
</label>
<select
id="rem-recurrence"
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className={inputClass}
>
{RECURRENCE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Link */}
{!isEdit && (
<div>
<label htmlFor="rem-link" className={labelClass}>
Link (optional)
</label>
<input
id="rem-link"
type="text"
value={link}
onChange={(e) => setLink(e.target.value)}
className={inputClass}
placeholder="/projects/abc or https://..."
/>
</div>
)}
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-2">
<div>
{isEdit && (
<button
type="button"
onClick={handleDelete}
disabled={isPending}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
>
Delete
</button>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : isEdit ? "Update" : "Create Reminder"}
</button>
</div>
</div>
</form>
</div>
</div>
);
}