547 lines
21 KiB
TypeScript
547 lines
21 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { UserSelectContent } from '@/components/ui/user-select-content'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Users, Filter, CheckSquare, Wand2, MessageSquare, Pencil } from 'lucide-react'
|
|
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
|
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
|
|
|
|
interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null }
|
|
interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null }
|
|
interface SimpleDesignation { id: string; name: string }
|
|
|
|
interface BulkAssignClientProps {
|
|
users: SimpleUser[]
|
|
clients: SimpleClient[]
|
|
designations: SimpleDesignation[]
|
|
}
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: '_all', label: 'All statuses' },
|
|
{ value: 'NOT_STARTED', label: 'Not Started' },
|
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
|
{ value: 'BLOCKED', label: 'Blocked' },
|
|
]
|
|
|
|
const DEPT_OPTIONS = [
|
|
{ value: '_all', label: 'All departments' },
|
|
{ value: 'PERSONAL_LINES', label: 'Personal Lines' },
|
|
{ value: 'COMMERCIAL_LINES', label: 'Commercial Lines' },
|
|
{ value: 'CLAIMS', label: 'Claims' },
|
|
{ value: 'BENEFITS', label: 'Benefits' },
|
|
{ value: 'OTHER', label: 'Other' },
|
|
]
|
|
|
|
interface ClientTaskSummary {
|
|
id: string
|
|
name: string
|
|
estimatedTasks: number
|
|
}
|
|
|
|
interface GenResult {
|
|
dryRun: boolean
|
|
clientsFound: number
|
|
totalEstimatedTasks: number
|
|
advocateName: string | null
|
|
clients: ClientTaskSummary[]
|
|
tasksCreated: number
|
|
tasksAssigned: number
|
|
}
|
|
|
|
export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) {
|
|
const [tasks, setTasks] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [assignTo, setAssignTo] = useState('')
|
|
const [assigning, setAssigning] = useState(false)
|
|
const [editingTask, setEditingTask] = useState<any | null>(null)
|
|
|
|
// Generate & assign state
|
|
const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation')
|
|
const [genAdvocate, setGenAdvocate] = useState('')
|
|
const [genDesignation, setGenDesignation] = useState('')
|
|
const [genClientId, setGenClientId] = useState('')
|
|
const [previewing, setPreviewing] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
const [preview, setPreview] = useState<GenResult | null>(null)
|
|
const [genResult, setGenResult] = useState<GenResult | null>(null)
|
|
|
|
// Filters
|
|
const [clientFilter, setClientFilter] = useState('_all')
|
|
const [designationFilter, setDesignationFilter] = useState('_all')
|
|
const [statusFilter, setStatusFilter] = useState('NOT_STARTED')
|
|
const [deptFilter, setDeptFilter] = useState('_all')
|
|
|
|
const fetchTasks = useCallback(async () => {
|
|
setLoading(true)
|
|
setSelected(new Set())
|
|
try {
|
|
const params = new URLSearchParams({ limit: '200' })
|
|
if (clientFilter !== '_all') params.set('clientId', clientFilter)
|
|
if (designationFilter !== '_all') params.set('designationId', designationFilter)
|
|
if (statusFilter !== '_all') params.set('status', statusFilter)
|
|
if (deptFilter !== '_all') params.set('department', deptFilter)
|
|
|
|
const res = await fetch(`/api/tasks?${params}`)
|
|
const data = await res.json()
|
|
setTasks(data.tasks || [])
|
|
} catch {
|
|
toast.error('Failed to load tasks')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [clientFilter, designationFilter, statusFilter, deptFilter])
|
|
|
|
useEffect(() => { fetchTasks() }, [fetchTasks])
|
|
|
|
const toggleSelect = (id: string) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
next.has(id) ? next.delete(id) : next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const toggleAll = () => {
|
|
if (selected.size === tasks.length) {
|
|
setSelected(new Set())
|
|
} else {
|
|
setSelected(new Set(tasks.map((t) => t.id)))
|
|
}
|
|
}
|
|
|
|
const handleAssign = async () => {
|
|
if (!assignTo || selected.size === 0) return
|
|
setAssigning(true)
|
|
try {
|
|
const res = await fetch('/api/tasks/bulk-assign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ taskIds: Array.from(selected), userId: assignTo }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error)
|
|
toast.success(`Assigned ${data.assigned} task(s)${data.skipped > 0 ? ` (${data.skipped} already assigned)` : ''}`)
|
|
setSelected(new Set())
|
|
fetchTasks()
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to assign tasks')
|
|
} finally {
|
|
setAssigning(false)
|
|
}
|
|
}
|
|
|
|
const buildGenBody = (dryRun: boolean) =>
|
|
genMode === 'client'
|
|
? { clientId: genClientId, dryRun }
|
|
: { advocateId: genAdvocate, designationId: genDesignation, dryRun }
|
|
|
|
const handlePreview = async () => {
|
|
setPreviewing(true)
|
|
setGenResult(null)
|
|
try {
|
|
const res = await fetch('/api/tasks/generate-and-assign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(buildGenBody(true)),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error)
|
|
if (data.totalEstimatedTasks === 0) {
|
|
toast.info(
|
|
data.clientsFound === 0
|
|
? 'No clients found matching this selection'
|
|
: 'No new tasks to generate — all tasks may already exist'
|
|
)
|
|
return
|
|
}
|
|
setPreview(data)
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to preview task generation')
|
|
} finally {
|
|
setPreviewing(false)
|
|
}
|
|
}
|
|
|
|
const handleConfirmGenerate = async () => {
|
|
setGenerating(true)
|
|
try {
|
|
const res = await fetch('/api/tasks/generate-and-assign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(buildGenBody(false)),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error)
|
|
setGenResult(data)
|
|
setPreview(null)
|
|
if (data.tasksCreated > 0) {
|
|
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
|
|
fetchTasks()
|
|
} else {
|
|
toast.info('No new tasks to generate')
|
|
}
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to generate tasks')
|
|
} finally {
|
|
setGenerating(false)
|
|
}
|
|
}
|
|
|
|
const statusColor: Record<string, string> = {
|
|
NOT_STARTED: 'secondary',
|
|
IN_PROGRESS: 'default',
|
|
COMPLETED: 'default',
|
|
BLOCKED: 'destructive',
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold flex items-center gap-3">
|
|
<Users className="h-8 w-8" />
|
|
Tasks
|
|
</h1>
|
|
<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>
|
|
<Tabs
|
|
value={genMode}
|
|
onValueChange={(v) => { setGenMode(v as 'advocate-designation' | 'client'); setGenResult(null) }}
|
|
>
|
|
<TabsList>
|
|
<TabsTrigger value="advocate-designation">By Advocate + Designation</TabsTrigger>
|
|
<TabsTrigger value="client">By Client</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{genMode === 'advocate-designation' ? (
|
|
<div className="flex items-end gap-3 flex-wrap mt-3">
|
|
<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>
|
|
<UserSelectContent
|
|
users={users}
|
|
prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
|
|
/>
|
|
</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={handlePreview} disabled={!genAdvocate || !genDesignation || previewing}>
|
|
{previewing ? 'Checking...' : 'Preview'}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-end gap-3 flex-wrap mt-3">
|
|
<div className="flex-1 min-w-[240px]">
|
|
<p className="text-sm font-medium mb-1.5">Client</p>
|
|
<Select value={genClientId || '_none'} onValueChange={(v) => { setGenClientId(v === '_none' ? '' : v); setGenResult(null) }}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select client..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_none">Select client</SelectItem>
|
|
{clients.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && (
|
|
<p className="text-sm text-destructive mt-1.5">
|
|
This client has no claims advocate assigned. Set one before generating tasks.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
onClick={handlePreview}
|
|
disabled={!genClientId || !clients.find((c) => c.id === genClientId)?.claimsAdvocateId || previewing}
|
|
>
|
|
{previewing ? 'Checking...' : 'Preview'}
|
|
</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}.`
|
|
: 'No new tasks to generate — all tasks may already exist.'}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<AlertDialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null) }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s)
|
|
and assign them to {preview?.advocateName}.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="max-h-64 overflow-y-auto rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Client</TableHead>
|
|
<TableHead className="text-right">Tasks</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{preview?.clients.map((c) => (
|
|
<TableRow key={c.id}>
|
|
<TableCell>{c.name}</TableCell>
|
|
<TableCell className="text-right">{c.estimatedTasks}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleConfirmGenerate} disabled={generating}>
|
|
{generating ? 'Generating...' : 'Confirm'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Filter bar */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Filter className="h-4 w-4" />
|
|
Filters
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Select value={clientFilter} onValueChange={setClientFilter}>
|
|
<SelectTrigger><SelectValue placeholder="All clients" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_all">All clients</SelectItem>
|
|
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={designationFilter} onValueChange={setDesignationFilter}>
|
|
<SelectTrigger><SelectValue placeholder="All designations" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_all">All designations</SelectItem>
|
|
{designations.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_OPTIONS.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={deptFilter} onValueChange={setDeptFilter}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{DEPT_OPTIONS.map((d) => <SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Assignment action bar */}
|
|
{selected.size > 0 && (
|
|
<Card className="border-primary">
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<span className="text-sm font-medium">
|
|
<CheckSquare className="inline h-4 w-4 mr-1" />
|
|
{selected.size} task{selected.size !== 1 ? 's' : ''} selected
|
|
</span>
|
|
<Select value={assignTo || '_none'} onValueChange={(v) => setAssignTo(v === '_none' ? '' : v)}>
|
|
<SelectTrigger className="w-[220px]">
|
|
<SelectValue placeholder="Assign to..." />
|
|
</SelectTrigger>
|
|
<UserSelectContent
|
|
users={users}
|
|
prefixItem={<SelectItem value="_none">Select user</SelectItem>}
|
|
/>
|
|
</Select>
|
|
<Button onClick={handleAssign} disabled={!assignTo || assigning}>
|
|
{assigning ? 'Assigning...' : 'Assign'}
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
|
Clear selection
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Tasks table */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
{loading ? (
|
|
<div className="text-center py-12 text-muted-foreground">Loading tasks...</div>
|
|
) : tasks.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">No tasks match the current filters</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={selected.size === tasks.length && tasks.length > 0}
|
|
onCheckedChange={toggleAll}
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Task</TableHead>
|
|
<TableHead>Client</TableHead>
|
|
<TableHead>Department</TableHead>
|
|
<TableHead>Policy #</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Renewal</TableHead>
|
|
<TableHead>Due Date</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Assigned To</TableHead>
|
|
<TableHead className="w-10"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{tasks.map((task) => (
|
|
<TableRow
|
|
key={task.id}
|
|
className={selected.has(task.id) ? 'bg-muted/50' : ''}
|
|
onClick={() => toggleSelect(task.id)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={selected.has(task.id)}
|
|
onCheckedChange={() => toggleSelect(task.id)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="font-medium">{task.title}</div>
|
|
{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>
|
|
<TableCell className="text-sm">{task.policy?.policyNumber || '—'}</TableCell>
|
|
<TableCell className="text-sm">{task.policy?.policyType || '—'}</TableCell>
|
|
<TableCell className="text-sm" suppressHydrationWarning>
|
|
{task.policy?.expirationDate ? formatRenewalDate(task.policy.expirationDate) : '—'}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
<span suppressHydrationWarning>{formatDate(task.dueDate)}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={(statusColor[task.status] as any) || 'secondary'}>
|
|
{task.status?.replace('_', ' ')}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{task.assignments?.length > 0
|
|
? task.assignments.map((a: any) => a.user?.displayName || a.user?.email).join(', ')
|
|
: <span className="text-muted-foreground">Unassigned</span>
|
|
}
|
|
</TableCell>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => setEditingTask(task)}
|
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors p-1 rounded hover:bg-muted"
|
|
title="Edit task"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{/* Task edit modal */}
|
|
{editingTask && (
|
|
<TaskEditModal
|
|
open={!!editingTask}
|
|
onOpenChange={(open) => { if (!open) setEditingTask(null) }}
|
|
task={editingTask as EditableTask}
|
|
onUpdated={() => {
|
|
setEditingTask(null)
|
|
fetchTasks()
|
|
}}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|