d0f04f13f8
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>
186 lines
7.3 KiB
TypeScript
186 lines
7.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|