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,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<WidgetProps> = {}) {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between px-1 pb-3">
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border border-gray-200 dark:border-gray-700 p-3">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-1 pb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
Open Tasks
|
||||
{openCount > 0 && (
|
||||
<span className="ml-2 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">
|
||||
{openCount}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{taskCounts?.overdue !== undefined && taskCounts.overdue > 0 && (
|
||||
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
{taskCounts.overdue} overdue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{!tasks || tasks.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-8 text-sm text-gray-400 dark:text-gray-500">
|
||||
No open 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={handleStatusChange}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 mt-2">
|
||||
<Link
|
||||
href={"/notifications?tab=tasks" as Route}
|
||||
className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,12 @@ function ChargeabilityIcon() {
|
||||
function GraphIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
|
||||
}
|
||||
function NotificationsIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
|
||||
}
|
||||
function BroadcastIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>;
|
||||
}
|
||||
function AdminIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
|
||||
}
|
||||
@@ -72,6 +78,7 @@ const navSections: NavSection[] = [
|
||||
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/notifications", label: "Notifications", icon: <NotificationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,6 +141,7 @@ const adminNavEntries: AdminEntry[] = [
|
||||
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
|
||||
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Broadcast Management</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 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>
|
||||
Send Broadcast
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].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-1/2 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="mt-2 h-3 w-1/3 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{broadcasts.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 broadcasts sent yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Title
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Target
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Recipients
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Sender
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
|
||||
{broadcasts.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{b.title}
|
||||
</p>
|
||||
{b.body && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
{b.body}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
{TARGET_LABELS[b.targetType] ?? b.targetType}
|
||||
{b.targetValue && (
|
||||
<span className="ml-1 text-gray-400 dark:text-gray-500">({b.targetValue})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-600 dark:text-gray-300">
|
||||
{b.recipientCount ?? 0}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
{b.sender?.name ?? b.sender?.email ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{b.sentAt ? formatDate(b.sentAt) : "Scheduled"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Broadcast Modal */}
|
||||
{showModal && (
|
||||
<BroadcastModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
@@ -19,8 +21,11 @@ function relativeTime(date: Date): string {
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
type TabKey = "all" | "tasks" | "reminders";
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { data: session, status } = useSession();
|
||||
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
||||
@@ -33,9 +38,27 @@ export function NotificationBell() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
|
||||
|
||||
const { data: notifications = [] } = trpc.notification.list.useQuery(
|
||||
{ limit: 20 },
|
||||
{ enabled: open && isAuthenticated, retry: false },
|
||||
{ enabled: open && isAuthenticated && activeTab === "all", retry: false },
|
||||
);
|
||||
|
||||
const { data: tasks = [] } = trpc.notification.listTasks.useQuery(
|
||||
{ status: "OPEN", limit: 10 },
|
||||
{ enabled: open && isAuthenticated && activeTab === "tasks", retry: false },
|
||||
);
|
||||
|
||||
const { data: reminders = [] } = trpc.notification.listReminders.useQuery(
|
||||
{ limit: 10 },
|
||||
{ enabled: open && isAuthenticated && activeTab === "reminders", retry: false },
|
||||
);
|
||||
|
||||
const markRead = trpc.notification.markRead.useMutation({
|
||||
@@ -45,6 +68,15 @@ export function NotificationBell() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notification.taskCounts.invalidate();
|
||||
void utils.notification.listTasks.invalidate();
|
||||
void utils.notification.unreadCount.invalidate();
|
||||
void utils.notification.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -67,6 +99,16 @@ export function NotificationBell() {
|
||||
markRead.mutate({ id });
|
||||
}
|
||||
|
||||
function handleTaskAction(id: string, taskStatus: string) {
|
||||
updateTaskStatus.mutate({ id, status: taskStatus as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "tasks", label: "Tasks" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
{/* Bell button */}
|
||||
@@ -90,22 +132,29 @@ export function NotificationBell() {
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{/* Unread notification badge (red) */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
|
||||
<span className="absolute top-0.5 right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Task count badge (orange) */}
|
||||
{openTaskCount > 0 && (
|
||||
<span className="absolute -bottom-0.5 right-0.5 flex items-center justify-center min-w-[14px] h-3.5 px-0.5 text-[9px] font-bold text-white bg-orange-500 rounded-full leading-none">
|
||||
{openTaskCount > 99 ? "99+" : openTaskCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
Notifications
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
{unreadCount > 0 && activeTab === "all" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
@@ -117,48 +166,174 @@ export function NotificationBell() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isUnread = n.readAt === null;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isUnread) handleMarkOne(n.id);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
|
||||
}`}
|
||||
>
|
||||
<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 ? "" : "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}
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-800">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 px-3 py-2 text-xs 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.key === "tasks" && openTaskCount > 0 && (
|
||||
<span className="ml-1 inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-orange-500 rounded-full">
|
||||
{openTaskCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-80 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{activeTab === "all" && (
|
||||
<>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isUnread = n.readAt === null;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isUnread) handleMarkOne(n.id);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
|
||||
}`}
|
||||
>
|
||||
<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 ? "" : "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>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{relativeTime(new Date(n.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "tasks" && (
|
||||
<>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No open tasks
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((t) => (
|
||||
<div key={t.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug truncate">
|
||||
{t.title}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{relativeTime(new Date(n.createdAt))}
|
||||
</p>
|
||||
{t.dueDate && (
|
||||
<p className={`mt-0.5 text-xs ${new Date(t.dueDate).getTime() < Date.now() ? "text-red-600 dark:text-red-400 font-semibold" : "text-gray-400 dark:text-gray-500"}`}>
|
||||
Due: {new Date(t.dueDate).toLocaleDateString("en-GB")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTaskAction(t.id, "DONE")}
|
||||
disabled={updateTaskStatus.isPending}
|
||||
className="p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||
title="Mark as Done"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTaskAction(t.id, "DISMISSED")}
|
||||
disabled={updateTaskStatus.isPending}
|
||||
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "reminders" && (
|
||||
<>
|
||||
{reminders.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No reminders
|
||||
</div>
|
||||
) : (
|
||||
reminders.map((r) => (
|
||||
<div key={r.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<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-1">
|
||||
{r.body}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{r.nextRemindAt
|
||||
? new Date(r.nextRemindAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: relativeTime(new Date(r.createdAt))}
|
||||
{r.recurrence && (
|
||||
<span className="ml-2 text-brand-500 dark:text-brand-400">{r.recurrence}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 px-4 py-2">
|
||||
<Link
|
||||
href={"/notifications" as Route}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
|
||||
const PRIORITY_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
URGENT: { bg: "bg-red-100 dark:bg-red-900/30", text: "text-red-700 dark:text-red-300", label: "Urgent" },
|
||||
HIGH: { bg: "bg-orange-100 dark:bg-orange-900/30", text: "text-orange-700 dark:text-orange-300", label: "High" },
|
||||
NORMAL: { bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-300", label: "Normal" },
|
||||
LOW: { bg: "bg-gray-100 dark:bg-gray-800", text: "text-gray-500 dark:text-gray-400", label: "Low" },
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: "Open",
|
||||
IN_PROGRESS: "In Progress",
|
||||
DONE: "Done",
|
||||
DISMISSED: "Dismissed",
|
||||
};
|
||||
|
||||
function isOverdue(dueDate: string | Date | null | undefined): boolean {
|
||||
if (!dueDate) return false;
|
||||
const d = typeof dueDate === "string" ? new Date(dueDate) : dueDate;
|
||||
return d.getTime() < Date.now();
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
function isApprovalAction(action: string | null | undefined): boolean {
|
||||
return !!action && action.startsWith("approve_");
|
||||
}
|
||||
|
||||
export interface TaskCardProps {
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
priority: string;
|
||||
taskStatus?: string | null;
|
||||
taskAction?: string | null;
|
||||
dueDate?: string | Date | null;
|
||||
entityType?: string | null;
|
||||
link?: string | null;
|
||||
createdAt: string | Date;
|
||||
completedBy?: string | null;
|
||||
};
|
||||
onStatusChange?: (id: string, status: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onStatusChange, compact }: TaskCardProps) {
|
||||
const priority = PRIORITY_STYLES[task.priority] ?? PRIORITY_STYLES.NORMAL!;
|
||||
const status = task.taskStatus ?? "OPEN";
|
||||
const overdue = isOverdue(task.dueDate) && status !== "DONE" && status !== "DISMISSED";
|
||||
const isDone = status === "DONE" || status === "DISMISSED";
|
||||
const showApprovalButtons = isApprovalAction(task.taskAction) && !isDone;
|
||||
|
||||
const titleContent = (
|
||||
<span className={`text-sm font-medium leading-snug ${isDone ? "line-through text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-gray-100"}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-gray-200 dark:border-gray-700 ${isDone ? "bg-gray-50 dark:bg-gray-900/40" : "bg-white dark:bg-gray-900/70"} ${compact ? "p-3" : "p-4"} transition-colors`}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{task.link && !isDone ? (
|
||||
<Link href={task.link as Route} className="hover:underline">
|
||||
{titleContent}
|
||||
</Link>
|
||||
) : (
|
||||
titleContent
|
||||
)}
|
||||
|
||||
{!compact && task.body && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{task.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority badge */}
|
||||
<span className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${priority.bg} ${priority.text}`}>
|
||||
{priority.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className={`flex items-center gap-3 ${compact ? "mt-1.5" : "mt-2"} text-xs`}>
|
||||
{/* Status */}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</span>
|
||||
|
||||
{/* Due date */}
|
||||
{task.dueDate && (
|
||||
<span className={overdue ? "font-semibold text-red-600 dark:text-red-400" : "text-gray-400 dark:text-gray-500"}>
|
||||
{overdue ? "Overdue: " : "Due: "}
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Entity type */}
|
||||
{!compact && task.entityType && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{task.entityType}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Completed by */}
|
||||
{isDone && task.completedBy && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Completed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isDone && onStatusChange && (
|
||||
<div className={`flex items-center gap-2 ${compact ? "mt-2" : "mt-3"}`}>
|
||||
{showApprovalButtons ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatusChange(task.id, "DONE")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatusChange(task.id, "DISMISSED")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{status === "OPEN" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatusChange(task.id, "IN_PROGRESS")}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-1 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatusChange(task.id, "DONE")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatusChange(task.id, "DISMISSED")}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Dismiss
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user