Tasks: notes field, status toggle, employee view-as, assign page improvements, generate-and-assign
This commit is contained in:
parent
9bb2e14151
commit
73c93d3c7f
9 changed files with 975 additions and 233 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
400
ondeck/src/app/(dashboard)/tasks/page-client.tsx
Normal file
400
ondeck/src/app/(dashboard)/tasks/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
83
ondeck/src/app/api/tasks/[id]/route.ts
Normal file
83
ondeck/src/app/api/tasks/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
211
ondeck/src/app/api/tasks/generate-and-assign/route.ts
Normal file
211
ondeck/src/app/api/tasks/generate-and-assign/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
93
ondeck/src/lib/entra.ts
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue