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,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<string>("all");
|
||||
const [targetValue, setTargetValue] = useState("");
|
||||
const [priority, setPriority] = useState("NORMAL");
|
||||
const [channel, setChannel] = useState("in_app");
|
||||
const [link, setLink] = useState("");
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ recipientCount: number } | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(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 (
|
||||
<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) { onSuccess(); 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") { onSuccess(); onClose(); } }}
|
||||
>
|
||||
<div className="px-6 py-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Broadcast Sent</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Sent to {result.recipientCount} recipient{result.recipientCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSuccess(); onClose(); }}
|
||||
className="mt-6 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">Send Broadcast</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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="bc-title" className={labelClass}>
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="bc-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
className={inputClass}
|
||||
required
|
||||
placeholder="Broadcast title..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label htmlFor="bc-body" className={labelClass}>
|
||||
Message (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="bc-body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
className={`${inputClass} resize-none`}
|
||||
placeholder="Message body..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target type */}
|
||||
<div>
|
||||
<label htmlFor="bc-target" className={labelClass}>
|
||||
Target Audience
|
||||
</label>
|
||||
<select
|
||||
id="bc-target"
|
||||
value={targetType}
|
||||
onChange={(e) => { setTargetType(e.target.value); setTargetValue(""); }}
|
||||
className={inputClass}
|
||||
>
|
||||
{TARGET_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target value selector */}
|
||||
{targetType === "role" && (
|
||||
<div>
|
||||
<label htmlFor="bc-role" className={labelClass}>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="bc-role"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select a role...</option>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetType === "project" && (
|
||||
<div>
|
||||
<label htmlFor="bc-project" className={labelClass}>
|
||||
Project ID
|
||||
</label>
|
||||
<input
|
||||
id="bc-project"
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Project ID..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetType === "orgUnit" && (
|
||||
<div>
|
||||
<label htmlFor="bc-orgunit" className={labelClass}>
|
||||
Org Unit ID
|
||||
</label>
|
||||
<input
|
||||
id="bc-orgunit"
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Org Unit ID..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority + Channel */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="bc-priority" className={labelClass}>
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
id="bc-priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bc-channel" className={labelClass}>
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
id="bc-channel"
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{CHANNEL_OPTIONS.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label htmlFor="bc-link" className={labelClass}>
|
||||
Link (optional)
|
||||
</label>
|
||||
<input
|
||||
id="bc-link"
|
||||
type="text"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="/some/page 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-end gap-3 pt-2">
|
||||
<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 ? "Sending..." : "Send Broadcast"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user