Tasks: notes field, status toggle, employee view-as, assign page improvements, generate-and-assign

This commit is contained in:
lorentz 2026-03-25 14:38:02 +00:00
parent 9bb2e14151
commit 73c93d3c7f
9 changed files with 975 additions and 233 deletions

View file

@ -19,6 +19,9 @@ model User {
email String @unique
displayName String? @map("display_name")
department String?
jobTitle String? @map("job_title")
photoUrl String? @map("photo_url")
office String?
isActive Boolean @default(true) @map("is_active")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
@ -286,6 +289,7 @@ model Task {
createdBy String? @map("created_by")
completedAt DateTime? @map("completed_at")
completedBy String? @map("completed_by")
notes String? @db.Text
naReason String? @map("na_reason") @db.Text
cancelledReason String? @map("cancelled_reason") @db.Text
createdAt DateTime @default(now()) @map("created_at")

View file

@ -9,7 +9,9 @@ import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
@ -21,10 +23,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Users, Filter, CheckSquare } from 'lucide-react'
import { Users, Filter, CheckSquare, Wand2, MessageSquare } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SimpleUser { id: string; displayName: string | null; email: string }
interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null }
interface SimpleClient { id: string; name: string }
interface SimpleDesignation { id: string; name: string }
@ -50,6 +52,15 @@ const DEPT_OPTIONS = [
{ value: 'OTHER', label: 'Other' },
]
interface GenResult {
tasksCreated: number
tasksAssigned: number
clientsFound: number
groupsProcessed: number
advocateName: string | null
message?: string
}
export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) {
const [tasks, setTasks] = useState<any[]>([])
const [loading, setLoading] = useState(false)
@ -57,6 +68,12 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
const [assignTo, setAssignTo] = useState('')
const [assigning, setAssigning] = useState(false)
// Generate & assign state
const [genAdvocate, setGenAdvocate] = useState('')
const [genDesignation, setGenDesignation] = useState('')
const [generating, setGenerating] = useState(false)
const [genResult, setGenResult] = useState<GenResult | null>(null)
// Filters
const [clientFilter, setClientFilter] = useState('_all')
const [designationFilter, setDesignationFilter] = useState('_all')
@ -122,6 +139,63 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
}
}
const handleGenerateAndAssign = async () => {
if (!genAdvocate || !genDesignation) return
setGenerating(true)
setGenResult(null)
try {
const res = await fetch('/api/tasks/generate-and-assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ advocateId: genAdvocate, designationId: genDesignation }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setGenResult(data)
if (data.tasksCreated > 0) {
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
fetchTasks()
} else if (data.clientsFound === 0) {
toast.info('No clients found with this advocate and designation')
} else {
toast.info(data.message || 'No new tasks to generate')
}
} catch (err: any) {
toast.error(err.message || 'Failed to generate tasks')
} finally {
setGenerating(false)
}
}
const renderUserOptions = () => {
const claims = users.filter((u) => u.department?.toLowerCase().includes('claims'))
const others = users.filter((u) => !u.department?.toLowerCase().includes('claims'))
return (
<>
{claims.length > 0 && (
<SelectGroup>
<SelectLabel>Claims</SelectLabel>
{claims.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
</SelectGroup>
)}
{others.length > 0 && (
<SelectGroup>
<SelectLabel>All Staff</SelectLabel>
{others.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
</SelectGroup>
)}
</>
)
}
const statusColor: Record<string, string> = {
NOT_STARTED: 'secondary',
IN_PROGRESS: 'default',
@ -134,11 +208,63 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Users className="h-8 w-8" />
Bulk Task Assignment
Tasks
</h1>
<p className="text-muted-foreground mt-1">Filter tasks and assign them to a team member</p>
<p className="text-muted-foreground mt-1">Generate and assign tasks to claims advocates</p>
</div>
{/* Generate & Assign card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Wand2 className="h-4 w-4" />
Generate &amp; Assign Tasks
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-medium mb-1.5">Claims Advocate</p>
<Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select advocate..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select advocate</SelectItem>
{renderUserOptions()}
</SelectContent>
</Select>
</div>
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-medium mb-1.5">Designation</p>
<Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select designation..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select designation</SelectItem>
{designations.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerateAndAssign} disabled={!genAdvocate || !genDesignation || generating}>
{generating ? 'Generating...' : 'Generate & Assign'}
</Button>
</div>
{genResult && (
<p className="mt-3 text-sm text-muted-foreground">
{genResult.tasksCreated > 0
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
: genResult.clientsFound === 0
? `No clients found assigned to this advocate with that designation.`
: genResult.message || 'No new tasks to generate — all tasks may already exist.'}
</p>
)}
</CardContent>
</Card>
{/* Filter bar */}
<Card>
<CardHeader>
@ -197,11 +323,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select user</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
{renderUserOptions()}
</SelectContent>
</Select>
<Button onClick={handleAssign} disabled={!assignTo || assigning}>
@ -259,6 +381,12 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
{task.description && (
<div className="text-xs text-muted-foreground line-clamp-1">{task.description}</div>
)}
{task.notes && (
<div className="mt-1 flex items-start gap-1 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3 mt-0.5 shrink-0" />
<span className="line-clamp-2">{task.notes}</span>
</div>
)}
</TableCell>
<TableCell className="text-sm">{task.client?.name || '—'}</TableCell>
<TableCell className="text-sm">{task.department?.replace('_', ' ') || '—'}</TableCell>

View file

@ -18,7 +18,7 @@ export default async function BulkAssignPage() {
const [users, clients, designations] = await Promise.all([
prisma.user.findMany({
where: { isActive: true },
select: { id: true, displayName: true, email: true },
select: { id: true, displayName: true, email: true, department: true },
orderBy: { displayName: 'asc' },
}),
prisma.client.findMany({

View file

@ -0,0 +1,400 @@
'use client'
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface TaskUser { displayName: string | null; email: string }
interface TaskAssignment { id: string; user: TaskUser }
interface Task {
id: string
title: string
description: string | null
status: string
priority: string
department: string
dueDate: string | Date
notes: string | null
client: { id: string; name: string } | null
policy: { id: string; policyNumber: string | null; expirationDate: string | Date } | null
assignments: TaskAssignment[]
}
interface SimpleUser { id: string; displayName: string | null; email: string }
interface TasksClientProps {
initialTasks: Task[]
currentUserId: string
isPrivileged: boolean
users: SimpleUser[]
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
COMPLETED: 'bg-green-500/15 text-green-700 dark:text-green-400',
IN_PROGRESS: 'bg-blue-500/15 text-blue-700 dark:text-blue-400',
NOT_STARTED: 'bg-muted text-muted-foreground',
BLOCKED: 'bg-red-500/15 text-red-700 dark:text-red-400',
NA: 'bg-muted text-muted-foreground',
CANCELLED: 'bg-muted text-muted-foreground line-through',
}
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${map[status] ?? 'bg-muted text-muted-foreground'}`}>
{status.replace('_', ' ')}
</span>
)
}
function PriorityDot({ priority }: { priority: string }) {
const map: Record<string, string> = {
HIGH: 'text-red-500',
MEDIUM: 'text-yellow-500',
LOW: 'text-green-500',
}
return (
<span className={`text-xs font-medium ${map[priority] ?? 'text-muted-foreground'}`}>
{priority}
</span>
)
}
function TaskCard({ task: initial }: { task: Task }) {
const [task, setTask] = useState(initial)
const [noteOpen, setNoteOpen] = useState(false)
const [noteText, setNoteText] = useState(initial.notes ?? '')
const [noteStatus, setNoteStatus] = useState(initial.status)
const [saving, setSaving] = useState(false)
const [toggling, setToggling] = useState(false)
const now = new Date()
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
const isCompleted = task.status === 'COMPLETED'
const patch = async (body: object) => {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to update task')
}
return res.json()
}
const handleToggleStatus = async () => {
setToggling(true)
try {
const newStatus = isCompleted ? 'NOT_STARTED' : 'COMPLETED'
const updated = await patch({ status: newStatus })
setTask((t) => ({ ...t, status: updated.status, notes: updated.notes }))
setNoteStatus(updated.status)
toast.success(newStatus === 'COMPLETED' ? 'Task marked complete' : 'Task reopened')
} catch (err: any) {
toast.error(err.message)
} finally {
setToggling(false)
}
}
const handleSaveNote = async () => {
setSaving(true)
try {
const body: any = { notes: noteText }
if (noteStatus !== task.status) body.status = noteStatus
const updated = await patch(body)
setTask((t) => ({ ...t, status: updated.status, notes: updated.notes }))
setNoteOpen(false)
toast.success('Note saved')
} catch (err: any) {
toast.error(err.message)
} finally {
setSaving(false)
}
}
return (
<div
className={`rounded-lg border p-4 transition-shadow hover:shadow-md ${
isCompleted
? 'border-border/50 opacity-75'
: isOverdue
? 'border-red-500/40 bg-red-500/5 dark:bg-red-500/10'
: 'border-border'
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{/* Title row */}
<div className="flex flex-wrap items-center gap-2 mb-1.5">
<h3 className={`font-semibold ${isCompleted ? 'line-through text-muted-foreground' : ''}`}>
{task.title}
</h3>
<StatusBadge status={task.status} />
<PriorityDot priority={task.priority} />
{task.notes && <MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
{task.description && (
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{task.description}</p>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
{task.client && (
<span>
<span className="text-muted-foreground">Client:</span>{' '}
<span className="font-medium">{task.client.name}</span>
</span>
)}
{task.policy?.policyNumber && (
<span>
<span className="text-muted-foreground">Policy:</span>{' '}
<span className="font-medium">{task.policy.policyNumber}</span>
</span>
)}
<span>
<span className="text-muted-foreground">Due:</span>{' '}
<span className={`font-medium ${isOverdue ? 'text-red-500' : ''}`}>
<span suppressHydrationWarning>{formatDate(task.dueDate)}</span>
</span>
</span>
</div>
{task.assignments.length > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
Assigned to:{' '}
{task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')}
</p>
)}
{/* Note — always visible */}
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm">
<span className="font-medium text-muted-foreground mr-1.5">Note:</span>
{task.notes
? <span className="whitespace-pre-wrap">{task.notes}</span>
: <span className="italic text-muted-foreground/60">No note click Note to add one</span>
}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant={isCompleted ? 'outline' : 'default'}
onClick={handleToggleStatus}
disabled={toggling}
className="gap-1.5"
>
{isCompleted
? <><RotateCcw className="h-3.5 w-3.5" /> Reopen</>
: <><CheckCircle2 className="h-3.5 w-3.5" /> Complete</>
}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setNoteOpen((o) => !o); setNoteText(task.notes ?? '') }}
className="gap-1.5"
>
<MessageSquare className="h-3.5 w-3.5" />
{task.notes ? 'Edit Note' : 'Note'}
</Button>
</div>
</div>
{/* Note panel */}
{noteOpen && (
<div className="mt-3 pt-3 border-t border-border space-y-2">
<Textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
placeholder="Add a note..."
rows={3}
className="text-sm"
/>
<div className="flex items-center gap-2 flex-wrap">
<Select value={noteStatus} onValueChange={setNoteStatus}>
<SelectTrigger className="w-[160px] h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
</SelectContent>
</Select>
<Button size="sm" onClick={handleSaveNote} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setNoteOpen(false)}>
Cancel
</Button>
</div>
</div>
)}
</div>
)
}
export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }: TasksClientProps) {
const [tasks, setTasks] = useState(initialTasks)
const [filter, setFilter] = useState<'assigned' | 'completed' | 'all'>('assigned')
const [viewingUserId, setViewingUserId] = useState(currentUserId)
const [loadingTasks, setLoadingTasks] = useState(false)
const viewingUser = users.find((u) => u.id === viewingUserId)
const isViewingSelf = viewingUserId === currentUserId
const fetchForUser = useCallback(async (uid: string) => {
setLoadingTasks(true)
try {
const params = new URLSearchParams({ userId: uid, limit: '100' })
const res = await fetch(`/api/tasks?${params}`)
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setTasks(data.tasks || [])
} catch (err: any) {
toast.error(err.message || 'Failed to load tasks')
} finally {
setLoadingTasks(false)
}
}, [])
const handleUserChange = (uid: string) => {
setViewingUserId(uid)
if (uid === currentUserId) {
setTasks(initialTasks)
} else {
fetchForUser(uid)
}
}
const now = new Date()
const overdue = tasks.filter((t) => new Date(t.dueDate) < now && t.status !== 'COMPLETED')
const dueToday = tasks.filter((t) => new Date(t.dueDate).toDateString() === now.toDateString() && t.status !== 'COMPLETED')
const upcoming = tasks.filter((t) => new Date(t.dueDate) > now && t.status !== 'COMPLETED')
const visibleTasks =
filter === 'assigned' ? tasks.filter((t) => t.status !== 'COMPLETED') :
filter === 'completed' ? tasks.filter((t) => t.status === 'COMPLETED') :
tasks
return (
<div className="space-y-8">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
{!isViewingSelf && <Eye className="h-7 w-7 text-muted-foreground" />}
{isViewingSelf ? 'My Tasks' : `${viewingUser?.displayName ?? viewingUser?.email ?? 'Employee'}'s Tasks`}
</h1>
<p className="text-muted-foreground mt-1">
{isViewingSelf ? 'Manage your policy renewal tasks and assignments' : 'Viewing as another employee'}
</p>
</div>
{isPrivileged && (
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground whitespace-nowrap">Viewing as:</span>
<Select value={viewingUserId} onValueChange={handleUserChange}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={currentUserId}>Myself</SelectItem>
{users.filter((u) => u.id !== currentUserId).map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{overdue.length}</div>
<p className="text-xs text-muted-foreground">Require immediate attention</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Due Today</CardTitle>
<Clock className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-500">{dueToday.length}</div>
<p className="text-xs text-muted-foreground">Tasks due today</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
<CheckSquare className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-500">{upcoming.length}</div>
<p className="text-xs text-muted-foreground">Future tasks</p>
</CardContent>
</Card>
</div>
{/* Task list */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>All Tasks</CardTitle>
<div className="flex rounded-md border border-border overflow-hidden text-sm">
{(['assigned', 'completed', 'all'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 capitalize transition-colors ${
filter === f
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
>
{f}
</button>
))}
</div>
</CardHeader>
<CardContent>
{visibleTasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No tasks assigned</p>
<p className="text-sm mt-2">Tasks will appear here when they are assigned to you</p>
</div>
) : (
<div className="space-y-3">
{visibleTasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -2,236 +2,56 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { CheckSquare, Clock, AlertCircle } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { TasksClient } from './page-client'
export const dynamic = 'force-dynamic'
export default async function TasksPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
if (!session?.user) redirect('/auth/signin')
const userId = (session.user as any)?.id
const userRoles = (session.user as any)?.roles || []
const isPrivileged = userRoles.includes('Admin') || userRoles.includes('Manager')
// Fetch tasks assigned to the current user
const myTasks = await prisma.task.findMany({
where: {
assignments: {
some: {
userId,
const [myTasksRaw, users] = await Promise.all([
prisma.task.findMany({
where: { assignments: { some: { userId } } },
include: {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, expirationDate: true } },
assignments: {
include: { user: { select: { displayName: true, email: true } } },
},
},
},
include: {
client: {
select: {
id: true,
name: true,
},
},
policy: {
select: {
id: true,
policyNumber: true,
expirationDate: true,
},
},
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
take: 50,
})
orderBy: { dueDate: 'asc' },
take: 100,
}),
isPrivileged
? prisma.user.findMany({
where: { isActive: true },
select: { id: true, displayName: true, email: true },
orderBy: { displayName: 'asc' },
})
: Promise.resolve([]),
])
// Calculate task statistics
const now = new Date()
const overdueTasks = myTasks.filter(task => new Date(task.dueDate) < now && task.status !== 'COMPLETED')
const dueTodayTasks = myTasks.filter(task => {
const dueDate = new Date(task.dueDate)
return dueDate.toDateString() === now.toDateString() && task.status !== 'COMPLETED'
})
const upcomingTasks = myTasks.filter(task => {
const dueDate = new Date(task.dueDate)
return dueDate > now && task.status !== 'COMPLETED'
})
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':
return 'bg-green-100 text-green-800'
case 'IN_PROGRESS':
return 'bg-blue-100 text-blue-800'
case 'NOT_STARTED':
return 'bg-gray-100 text-gray-800'
case 'BLOCKED':
return 'bg-red-100 text-red-800'
case 'CANCELLED':
return 'bg-gray-100 text-gray-500'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'HIGH':
return 'text-red-600'
case 'MEDIUM':
return 'text-yellow-600'
case 'LOW':
return 'text-green-600'
default:
return 'text-gray-600'
}
}
const tasks = myTasksRaw.map((t) => ({
...t,
dueDate: t.dueDate.toISOString(),
policy: t.policy
? { ...t.policy, expirationDate: t.policy.expirationDate.toISOString() }
: null,
}))
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">My Tasks</h1>
<p className="text-muted-foreground mt-2">
Manage your policy renewal tasks and assignments
</p>
</div>
{/* Task Statistics */}
<div className="grid gap-4 md:grid-cols-3 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertCircle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{overdueTasks.length}</div>
<p className="text-xs text-muted-foreground">
Require immediate attention
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Due Today</CardTitle>
<Clock className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{dueTodayTasks.length}</div>
<p className="text-xs text-muted-foreground">
Tasks due today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
<CheckSquare className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{upcomingTasks.length}</div>
<p className="text-xs text-muted-foreground">
Future tasks
</p>
</CardContent>
</Card>
</div>
{/* Task List */}
<Card>
<CardHeader>
<CardTitle>All Tasks</CardTitle>
</CardHeader>
<CardContent>
{myTasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No tasks assigned</p>
<p className="text-sm mt-2">Tasks will appear here when they are assigned to you</p>
</div>
) : (
<div className="space-y-4">
{myTasks.map((task) => {
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
return (
<div
key={task.id}
className={`p-4 border rounded-lg hover:shadow-md transition-shadow ${
isOverdue ? 'border-red-200 bg-red-50' : ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-lg">{task.title}</h3>
<Badge className={getStatusColor(task.status)}>
{task.status.replace('_', ' ')}
</Badge>
<span className={`text-sm font-medium ${getPriorityColor(task.priority)}`}>
{task.priority}
</span>
</div>
{task.description && (
<p className="text-sm text-muted-foreground mb-3">
{task.description}
</p>
)}
<div className="flex flex-wrap gap-4 text-sm">
{task.client && (
<div>
<span className="text-muted-foreground">Client:</span>{' '}
<span className="font-medium">{task.client.name}</span>
</div>
)}
{task.policy && (
<div>
<span className="text-muted-foreground">Policy:</span>{' '}
<span className="font-medium">{task.policy.policyNumber}</span>
</div>
)}
<div>
<span className="text-muted-foreground">Due:</span>{' '}
<span className={`font-medium ${isOverdue ? 'text-red-600' : ''}`}>
{formatDate(task.dueDate)}
</span>
</div>
</div>
{task.assignments.length > 0 && (
<div className="mt-3 text-sm">
<span className="text-muted-foreground">Assigned to:</span>{' '}
{task.assignments.map((a, i) => (
<span key={a.id}>
{a.user.displayName}
{i < task.assignments.length - 1 ? ', ' : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
<TasksClient
initialTasks={tasks}
currentUserId={userId}
isPrivileged={isPrivileged}
users={users}
/>
</div>
)
}

View file

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* PATCH /api/tasks/[id]
* Update a task's status and/or notes.
* Body: { status?: 'COMPLETED' | 'NOT_STARTED', notes?: string }
* Any authenticated user who is assigned to the task (or Admin/Manager) may update it.
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const userId = (session.user as any).id
const userRoles = (session.user as any).roles || []
const isPrivileged = userRoles.includes('Admin') || userRoles.includes('Manager')
const task = await prisma.task.findUnique({
where: { id },
include: { assignments: { select: { userId: true } } },
})
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
const isAssigned = task.assignments.some((a) => a.userId === userId)
if (!isPrivileged && !isAssigned) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { status, notes } = body
const allowedStatuses = ['COMPLETED', 'NOT_STARTED', 'IN_PROGRESS']
if (status && !allowedStatuses.includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const updateData: any = {}
if (status !== undefined) {
updateData.status = status
if (status === 'COMPLETED') {
updateData.completedAt = new Date()
updateData.completedBy = userId
} else {
updateData.completedAt = null
updateData.completedBy = null
}
}
if (notes !== undefined) updateData.notes = notes
const updated = await prisma.task.update({
where: { id },
data: updateData,
})
await prisma.auditLog.create({
data: {
userId,
action: 'TASK_UPDATED',
entityType: 'Task',
entityId: id,
oldValues: { status: task.status, notes: task.notes },
newValues: updateData,
},
})
return NextResponse.json(updated)
} catch (error) {
console.error('Task PATCH error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,211 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/tasks/generate-and-assign
* Find clients matching the given advocate + designation, generate tasks from
* active templates, and assign them to the advocate.
* Body: { advocateId: string, designationId: string }
* Requires Admin or Manager role.
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { advocateId, designationId } = body
if (!advocateId || !designationId) {
return NextResponse.json(
{ error: 'advocateId and designationId are required' },
{ status: 400 }
)
}
const advocate = await prisma.user.findUnique({
where: { id: advocateId },
select: { id: true, displayName: true, isActive: true },
})
if (!advocate || !advocate.isActive) {
return NextResponse.json({ error: 'Advocate not found or inactive' }, { status: 404 })
}
// Clients assigned to this advocate with this designation
const clients = await prisma.client.findMany({
where: {
claimsAdvocateId: advocateId,
OR: [{ designationId }, { designation2Id: designationId }],
},
select: { id: true },
})
if (clients.length === 0) {
return NextResponse.json({
tasksCreated: 0,
tasksAssigned: 0,
clientsFound: 0,
groupsProcessed: 0,
advocateName: advocate.displayName,
})
}
const clientIds = clients.map((c) => c.id)
const templates = await prisma.taskTemplate.findMany({
where: {
isActive: true,
OR: [{ designationId: null }, { designationId }],
},
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
})
if (templates.length === 0) {
return NextResponse.json({
tasksCreated: 0,
tasksAssigned: 0,
clientsFound: clients.length,
groupsProcessed: 0,
advocateName: advocate.displayName,
message: 'No active templates found for this designation',
})
}
const templateIds = templates.map((t) => t.id)
let tasksCreated = 0
let tasksAssigned = 0
let groupsProcessed = 0
// ── Policy groups with no template-generated tasks yet ──────────────────
const groups = await prisma.policyGroup.findMany({
where: {
clientId: { in: clientIds },
tasks: { none: { templateId: { not: null } } },
},
})
for (const group of groups) {
const renewalDate = new Date(group.renewalDate)
const tasksToCreate = templates.map((template) => {
const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: group.clientId,
policyGroupId: group.id,
templateId: template.id,
createdBy: (session.user as any).id,
}
})
const created = await prisma.task.createMany({ data: tasksToCreate })
tasksCreated += created.count
if (created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyGroupId: group.id, templateId: { in: templateIds } },
select: { id: true },
})
if (newTasks.length > 0) {
const assigned = await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({ taskId: t.id, userId: advocateId })),
skipDuplicates: true,
})
tasksAssigned += assigned.count
}
groupsProcessed++
}
}
// ── Ungrouped policies with no template-generated tasks yet ─────────────
const policies = await prisma.policy.findMany({
where: {
clientId: { in: clientIds },
policyGroupId: null,
tasks: { none: { templateId: { not: null } } },
},
})
for (const policy of policies) {
const anchorDate = new Date(policy.expirationDate)
const tasksToCreate = templates.map((template) => {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: policy.clientId,
policyId: policy.id,
templateId: template.id,
createdBy: (session.user as any).id,
}
})
const created = await prisma.task.createMany({ data: tasksToCreate })
tasksCreated += created.count
if (created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyId: policy.id, templateId: { in: templateIds } },
select: { id: true },
})
if (newTasks.length > 0) {
const assigned = await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({ taskId: t.id, userId: advocateId })),
skipDuplicates: true,
})
tasksAssigned += assigned.count
}
}
}
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'GENERATE_AND_ASSIGN_TASKS',
entityType: 'Task',
newValues: {
advocateId,
advocateName: advocate.displayName,
designationId,
clientsFound: clients.length,
tasksCreated,
tasksAssigned,
},
},
})
return NextResponse.json({
tasksCreated,
tasksAssigned,
clientsFound: clients.length,
groupsProcessed,
advocateName: advocate.displayName,
})
} catch (error) {
console.error('Generate and assign error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -17,6 +17,7 @@ export async function GET(request: NextRequest) {
const department = searchParams.get('department')
const designationId = searchParams.get('designationId')
const assignedToMe = searchParams.get('assignedToMe') === 'true'
const viewUserId = searchParams.get('userId')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
@ -33,12 +34,14 @@ export async function GET(request: NextRequest) {
],
}
}
if (assignedToMe) {
where.assignments = {
some: {
userId: (session.user as any).id,
},
if (viewUserId) {
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
where.assignments = { some: { userId: viewUserId } }
} else if (assignedToMe) {
where.assignments = { some: { userId: (session.user as any).id } }
}
const [tasks, total] = await Promise.all([

93
ondeck/src/lib/entra.ts Normal file
View file

@ -0,0 +1,93 @@
interface EntraUser {
id: string
displayName: string | null
mail: string | null
userPrincipalName: string
department: string | null
accountEnabled: boolean
jobTitle: string | null
}
interface GraphTokenResponse {
access_token: string
expires_in: number
token_type: string
}
let cachedToken: { token: string; expiresAt: number } | null = null
async function getGraphToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
return cachedToken.token
}
const tenantId = process.env.AZURE_AD_TENANT_ID
const clientId = process.env.AZURE_AD_CLIENT_ID
const clientSecret = process.env.AZURE_AD_CLIENT_SECRET
if (!tenantId || !clientId || !clientSecret) {
throw new Error('Azure AD credentials not configured (AZURE_AD_TENANT_ID, AZURE_AD_CLIENT_ID, AZURE_AD_CLIENT_SECRET)')
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default',
})
const res = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
}
)
if (!res.ok) {
const text = await res.text()
throw new Error(`Failed to obtain Graph token: ${res.status} ${text}`)
}
const data: GraphTokenResponse = await res.json()
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
}
return cachedToken.token
}
/**
* Fetch all enabled (non-guest) users from Entra ID via Microsoft Graph.
* Uses client_credentials requires User.Read.All application permission in Entra.
*/
export async function fetchEntraUsers(): Promise<EntraUser[]> {
const token = await getGraphToken()
const users: EntraUser[] = []
let url =
'https://graph.microsoft.com/v1.0/users' +
'?$select=id,displayName,mail,userPrincipalName,department,accountEnabled,jobTitle' +
'&$filter=accountEnabled eq true and userType eq \'Member\'' +
'&$top=999'
while (url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Graph API error: ${res.status} ${text}`)
}
const data: { value: EntraUser[]; '@odata.nextLink'?: string } = await res.json()
users.push(...data.value)
url = data['@odata.nextLink'] ?? ''
}
return users
}
export type { EntraUser }