Additional Service: ad hoc task creation on tasks page, client detail, policy group manager

This commit is contained in:
lorentz 2026-03-31 16:40:49 +00:00
parent c5c3d5a4ab
commit 0d8edc97da
9 changed files with 539 additions and 8 deletions

View file

@ -291,6 +291,7 @@ model Task {
completedAt DateTime? @map("completed_at")
completedBy String? @map("completed_by")
notes String? @db.Text
isAdHoc Boolean @default(false) @map("is_ad_hoc")
naReason String? @map("na_reason") @db.Text
cancelledReason String? @map("cancelled_reason") @db.Text
createdAt DateTime @default(now()) @map("created_at")

View file

@ -22,9 +22,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2 } from 'lucide-react'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { formatDate } from '@/lib/utils'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
interface TaskUser { displayName: string | null; email: string }
interface TaskAssignment { id: string; user: TaskUser }
@ -290,6 +291,7 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
const [transferOpen, setTransferOpen] = useState(false)
const [transferTo, setTransferTo] = useState('')
const [transferring, setTransferring] = useState(false)
const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
const [viewingUserId, setViewingUserId] = useState(currentUserId)
const [loadingTasks, setLoadingTasks] = useState(false)
const [clientFilter, setClientFilter] = useState('')
@ -420,8 +422,17 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
</p>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
className="gap-1.5"
onClick={() => setAdditionalServiceOpen(true)}
>
<Plus className="h-4 w-4" /> Additional Service
</Button>
{isPrivileged && (
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground whitespace-nowrap">Viewing as:</span>
<Select value={viewingUserId} onValueChange={handleUserChange}>
<SelectTrigger className="w-[200px]">
@ -458,6 +469,7 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
</Select>
</div>
)}
</div>
</div>
{/* Client filter */}
@ -729,6 +741,13 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
</DialogFooter>
</DialogContent>
</Dialog>
<AdditionalServiceModal
open={additionalServiceOpen}
onOpenChange={setAdditionalServiceOpen}
currentUserId={currentUserId}
onCreated={() => fetchTasks(viewingUserId, clientFilter || undefined)}
/>
</div>
)
}

View file

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = (session.user as any).id
const recent = await prisma.task.findMany({
where: { isAdHoc: true, createdBy: userId },
select: { id: true, title: true, description: true, priority: true, department: true },
orderBy: { createdAt: 'desc' },
take: 10,
distinct: ['title'],
})
return NextResponse.json(recent)
} catch (error) {
console.error('adhoc-recent error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -114,14 +114,16 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { clientId, policyId, templateId, assignedUserIds, ...taskData } = body
const { clientId, policyId, policyGroupId, templateId, isAdHoc, assignedUserIds, ...taskData } = body
const task = await prisma.task.create({
data: {
...taskData,
clientId,
policyId,
policyGroupId,
templateId,
isAdHoc: isAdHoc ?? false,
createdBy: (session.user as any).id,
assignments: assignedUserIds
? {

View file

@ -80,12 +80,12 @@ export function ClientCard({ client }: ClientCardProps) {
)}
</div>
{/* Next Policy Expiration */}
{/* Renewal Date */}
{nextPolicy && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Next expiration:</span>
<span className="text-muted-foreground">Renewal date:</span>
<span className="font-medium" suppressHydrationWarning>
{formatDate(nextPolicy.expirationDate)}
</span>

View file

@ -17,6 +17,7 @@ import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, U
import { Combobox } from '@/components/ui/combobox'
import { formatDate } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { toast } from 'sonner'
interface SimpleUser {
@ -37,6 +38,15 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
const [tasks, setTasks] = useState<any[]>(client.tasks || [])
const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
const [sessionUserId, setSessionUserId] = useState('')
useEffect(() => {
fetch('/api/auth/session')
.then((r) => r.json())
.then((s) => setSessionUserId(s?.user?.id ?? ''))
.catch(() => {})
}, [])
const refreshTasks = async () => {
try {
@ -321,7 +331,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</div>
<Badge variant={new Date(policy.expirationDate) < new Date() ? 'destructive' : 'default'}>
<span suppressHydrationWarning>
Expires {formatDate(policy.expirationDate)}
Renewal {formatDate(policy.expirationDate)}
</span>
</Badge>
</div>
@ -395,6 +405,11 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
<div className="flex justify-end">
<Button size="sm" className="gap-1.5" onClick={() => setAdditionalServiceOpen(true)}>
<Plus className="h-4 w-4" /> Additional Service
</Button>
</div>
{tasks.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
@ -423,6 +438,27 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
)}
</TabsContent>
<AdditionalServiceModal
open={additionalServiceOpen}
onOpenChange={setAdditionalServiceOpen}
currentUserId={sessionUserId}
context={{
clientId: client.id,
clientName: client.name,
policies: client.policies?.map((p: any) => ({
id: p.id,
policyNumber: p.policyNumber,
policyType: p.policyType,
})),
policyGroups: policyGroups.map((g: any) => ({
id: g.id,
name: g.name,
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
})),
}}
onCreated={refreshTasks}
/>
<TabsContent value="renewal-groups">
<PolicyGroupManager
clientId={client.id}

View file

@ -54,7 +54,7 @@ export function ClientTable({ clients }: ClientTableProps) {
<TableHead>Client</TableHead>
<TableHead>Location</TableHead>
<TableHead>Designation</TableHead>
<TableHead>Next Expiration</TableHead>
<TableHead>Renewal Date</TableHead>
<TableHead>Policy Type</TableHead>
<TableHead>Carrier</TableHead>
<TableHead>Executive</TableHead>

View file

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import {
Plus,
@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@ -94,6 +95,15 @@ export function PolicyGroupManager({
const [deleteTarget, setDeleteTarget] = useState<PolicyGroup | null>(null)
const [cancelTasks, setCancelTasks] = useState(false)
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
const [adhocGroupId, setAdhocGroupId] = useState<string | null>(null)
const [sessionUserId, setSessionUserId] = useState('')
useEffect(() => {
fetch('/api/auth/session')
.then((r) => r.json())
.then((s) => setSessionUserId(s?.user?.id ?? ''))
.catch(() => {})
}, [])
const assignedPolicyIds = new Set(
groups.flatMap((g) => g.policies.map((p) => p.id))
@ -322,6 +332,14 @@ export function PolicyGroupManager({
<div className="flex items-center gap-1 shrink-0">
{canManage && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setAdhocGroupId(group.id)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Additional Service
</Button>
<Button
variant="outline"
size="sm"
@ -571,6 +589,41 @@ export function PolicyGroupManager({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{adhocGroupId && (() => {
const grp = groups.find((g) => g.id === adhocGroupId)
return (
<AdditionalServiceModal
open={!!adhocGroupId}
onOpenChange={(open) => { if (!open) setAdhocGroupId(null) }}
currentUserId={sessionUserId}
context={{
clientId: clientId,
policyGroupId: adhocGroupId,
policyGroups: groups.map((g) => ({
id: g.id,
name: g.name,
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
})),
policies: grp?.policies.map((p) => ({
id: p.id,
policyNumber: p.policyNumber,
policyType: p.policyType,
})),
}}
onCreated={() => {
setGroups((prev) =>
prev.map((g) =>
g.id === adhocGroupId
? { ...g, _count: { tasks: g._count.tasks + 1 } }
: g
)
)
onTasksGenerated?.(1)
}}
/>
)
})()}
</div>
)
}

View file

@ -0,0 +1,394 @@
'use client'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Wand2 } from 'lucide-react'
interface SimpleUser {
id: string
displayName: string | null
email: string
department?: string | null
}
interface PolicyOption {
id: string
policyNumber: string | null
policyType: string | null
}
interface PolicyGroupOption {
id: string
name?: string | null
renewalDate?: string | null
}
interface RecentAdHoc {
id: string
title: string
description: string | null
priority: string
department: string
}
interface AdditionalServiceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
currentUserId: string
/** Pre-fill context — pass what's known from the current surface */
context?: {
clientId?: string
clientName?: string
policyId?: string
policyGroupId?: string
policies?: PolicyOption[]
policyGroups?: PolicyGroupOption[]
}
onCreated?: () => void
}
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']
const DEPARTMENTS = ['Claims', 'Personal Lines', 'Commercial Lines', 'Benefits', 'Life', 'Other']
const TIMINGS = ['BEFORE_RENEWAL', 'AT_RENEWAL', 'AFTER_RENEWAL', 'ONGOING']
export function AdditionalServiceModal({
open,
onOpenChange,
currentUserId,
context,
onCreated,
}: AdditionalServiceModalProps) {
const [level, setLevel] = useState<'client' | 'policy' | 'group'>('client')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [priority, setPriority] = useState('MEDIUM')
const [department, setDepartment] = useState(DEPARTMENTS[0])
const [dueDate, setDueDate] = useState('')
const [assignTo, setAssignTo] = useState(currentUserId)
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<SimpleUser[]>([])
const [recentAdHoc, setRecentAdHoc] = useState<RecentAdHoc[]>([])
// Client search (when no clientId pre-filled)
const [clientSearch, setClientSearch] = useState(context?.clientName ?? '')
const [clientId, setClientId] = useState(context?.clientId ?? '')
const [clientOptions, setClientOptions] = useState<{ id: string; name: string }[]>([])
const [clientDropOpen, setClientDropOpen] = useState(false)
const [selectedPolicyId, setSelectedPolicyId] = useState(context?.policyId ?? '')
const [selectedGroupId, setSelectedGroupId] = useState(context?.policyGroupId ?? '')
// Load users and recent ad hoc tasks when modal opens
useEffect(() => {
if (!open) return
// Reset form
setTitle('')
setDescription('')
setPriority('MEDIUM')
setDepartment(DEPARTMENTS[0])
setDueDate('')
setAssignTo(currentUserId)
setLevel(context?.policyId ? 'policy' : context?.policyGroupId ? 'group' : 'client')
setClientId(context?.clientId ?? '')
setClientSearch(context?.clientName ?? '')
setSelectedPolicyId(context?.policyId ?? '')
setSelectedGroupId(context?.policyGroupId ?? '')
fetch('/api/users?isActive=true&limit=200')
.then((r) => r.json())
.then((d) => setUsers(d.users || []))
.catch(() => {})
fetch('/api/tasks/adhoc-recent')
.then((r) => r.json())
.then((d) => setRecentAdHoc(Array.isArray(d) ? d : []))
.catch(() => {})
}, [open])
// Client search debounce
useEffect(() => {
if (context?.clientId || !clientSearch.trim()) { setClientOptions([]); return }
const t = setTimeout(async () => {
try {
const res = await fetch(`/api/clients?search=${encodeURIComponent(clientSearch)}&limit=20`)
const data = await res.json()
setClientOptions((data.clients || []).map((c: any) => ({ id: c.id, name: c.name })))
setClientDropOpen(true)
} catch {}
}, 250)
return () => clearTimeout(t)
}, [clientSearch, context?.clientId])
const applyRecent = (r: RecentAdHoc) => {
setTitle(r.title)
setDescription(r.description ?? '')
setPriority(r.priority)
setDepartment(r.department)
}
const handleSubmit = async () => {
if (!title.trim()) { toast.error('Title is required'); return }
if (!dueDate) { toast.error('Due date is required'); return }
if (!clientId) { toast.error('Please select a client'); return }
setSaving(true)
try {
const body: any = {
title: title.trim(),
description: description.trim() || null,
priority,
department,
dueDate: new Date(dueDate).toISOString(),
timing: 'ONGOING',
daysOffset: 0,
status: 'NOT_STARTED',
isAdHoc: true,
clientId,
assignedUserIds: [assignTo],
}
if (level === 'policy' && selectedPolicyId) body.policyId = selectedPolicyId
if (level === 'group' && selectedGroupId) body.policyGroupId = selectedGroupId
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
toast.success('Additional service task created')
onOpenChange(false)
onCreated?.()
} catch (err: any) {
toast.error(err.message || 'Failed to create task')
} finally {
setSaving(false)
}
}
const claimsUsers = users.filter((u) => u.department?.toLowerCase().includes('claims'))
const otherUsers = users.filter((u) => !u.department?.toLowerCase().includes('claims'))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Additional Service
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Recent suggestions */}
{recentAdHoc.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Wand2 className="h-3 w-3" /> Recent click to pre-fill
</p>
<div className="flex flex-wrap gap-1.5">
{recentAdHoc.slice(0, 5).map((r) => (
<button
key={r.id}
onClick={() => applyRecent(r)}
className="text-xs px-2 py-1 rounded-full border border-border bg-muted hover:bg-muted/80 truncate max-w-[180px]"
title={r.title}
>
{r.title}
</button>
))}
</div>
</div>
)}
{/* Title */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Title *</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Certificate of Insurance Request" />
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Description</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Additional details..." rows={2} className="text-sm" />
</div>
{/* Client — only show search if not pre-filled */}
{!context?.clientId && (
<div className="space-y-1.5 relative">
<label className="text-sm font-medium">Client *</label>
<Input
placeholder="Search client..."
value={clientId ? clientSearch : clientSearch}
onChange={(e) => { setClientSearch(e.target.value); setClientId(''); setClientDropOpen(true) }}
readOnly={!!clientId}
/>
{clientId && (
<button
onClick={() => { setClientId(''); setClientSearch('') }}
className="absolute right-2 top-8 text-xs text-muted-foreground hover:text-foreground"
>
</button>
)}
{clientDropOpen && clientOptions.length > 0 && !clientId && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
{clientOptions.map((c) => (
<button
key={c.id}
onClick={() => { setClientId(c.id); setClientSearch(c.name); setClientDropOpen(false) }}
className="w-full px-3 py-2 text-sm hover:bg-muted text-left"
>
{c.name}
</button>
))}
</div>
)}
</div>
)}
{/* Level */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Associate at level</label>
<div className="flex gap-2">
{(['client', 'policy', 'group'] as const).map((l) => (
<button
key={l}
onClick={() => setLevel(l)}
className={`flex-1 py-1.5 rounded-md border text-sm capitalize transition-colors ${
level === l ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted'
}`}
>
{l === 'group' ? 'Policy Group' : l === 'policy' ? 'Policy' : 'Client'}
</button>
))}
</div>
</div>
{/* Policy selector */}
{level === 'policy' && context?.policies && context.policies.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Policy</label>
<Select value={selectedPolicyId} onValueChange={setSelectedPolicyId}>
<SelectTrigger><SelectValue placeholder="Select policy..." /></SelectTrigger>
<SelectContent>
{context.policies.map((p) => (
<SelectItem key={p.id} value={p.id}>
{[p.policyNumber, p.policyType].filter(Boolean).join(' · ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Policy Group selector */}
{level === 'group' && context?.policyGroups && context.policyGroups.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Policy Group</label>
<Select value={selectedGroupId} onValueChange={setSelectedGroupId}>
<SelectTrigger><SelectValue placeholder="Select group..." /></SelectTrigger>
<SelectContent>
{context.policyGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name ?? g.renewalDate ?? g.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Priority + Department row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Priority</label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PRIORITIES.map((p) => <SelectItem key={p} value={p}>{p.charAt(0) + p.slice(1).toLowerCase()}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Department</label>
<Select value={department} onValueChange={setDepartment}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DEPARTMENTS.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* Due date */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Due Date *</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
</div>
{/* Assign to */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Assign to</label>
<Select value={assignTo} onValueChange={setAssignTo}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{claimsUsers.length > 0 && (
<SelectGroup>
<SelectLabel>Claims</SelectLabel>
{claimsUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectGroup>
)}
{otherUsers.length > 0 && (
<SelectGroup>
<SelectLabel>All Staff</SelectLabel>
{otherUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
{saving ? 'Creating...' : 'Create Task'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}