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,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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user