Task editing: reusable TaskEditModal, expanded PATCH API, edit button on all task surfaces

This commit is contained in:
lorentz 2026-04-10 14:20:57 +00:00
parent 8c5516f353
commit 04c87f111a
5 changed files with 532 additions and 6 deletions

View file

@ -22,8 +22,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Users, Filter, CheckSquare, Wand2, MessageSquare } from 'lucide-react'
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 }
@ -66,6 +67,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
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 [genAdvocate, setGenAdvocate] = useState('')
@ -333,6 +335,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
<TableHead>Due Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned To</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -382,11 +385,33 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
: <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>

View file

@ -21,10 +21,11 @@ import {
SelectValue,
} from '@/components/ui/select'
import { UserSelectContent } from '@/components/ui/user-select-content'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter } from 'lucide-react'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter, Pencil } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { formatDate, formatRenewalDate } from '@/lib/utils'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
interface TaskUser { displayName: string | null; email: string }
interface TaskAssignment { id: string; user: TaskUser }
@ -95,6 +96,7 @@ function TaskCard({ task: initial }: { task: Task }) {
const [naDialogOpen, setNaDialogOpen] = useState(false)
const [naReason, setNaReason] = useState('')
const [naSubmitting, setNaSubmitting] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
const [reminderDate, setReminderDate] = useState('')
@ -286,6 +288,15 @@ function TaskCard({ task: initial }: { task: Task }) {
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={() => setEditOpen(true)}
className="gap-1.5 text-muted-foreground hover:text-foreground"
title="Edit task"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant={isCompleted ? 'outline' : 'default'}
@ -390,6 +401,16 @@ function TaskCard({ task: initial }: { task: Task }) {
</DialogContent>
</Dialog>
{/* Edit modal */}
<TaskEditModal
open={editOpen}
onOpenChange={setEditOpen}
task={task as unknown as EditableTask}
onUpdated={(updated) => {
setTask((prev) => ({ ...prev, ...updated }))
}}
/>
{/* Notes panel */}
{noteOpen && (
<div className="mt-3 pt-3 border-t border-border space-y-3">

View file

@ -5,8 +5,7 @@ import { prisma } from '@/lib/db'
/**
* PATCH /api/tasks/[id]
* Update a task's status and/or notes.
* Body: { status?: 'COMPLETED' | 'NOT_STARTED', notes?: string }
* Update any editable fields on a task.
* Any authenticated user who is assigned to the task (or Admin/Manager) may update it.
*/
export async function PATCH(
@ -39,7 +38,11 @@ export async function PATCH(
}
const body = await request.json()
const { status, notes, naReason, imageRightFiled, reminderDate } = body
const {
status, notes, naReason, imageRightFiled, reminderDate,
title, description, priority, department, dueDate,
clientId, policyId, policyGroupId, assignedUserIds,
} = body
const allowedStatuses = ['COMPLETED', 'NOT_STARTED', 'IN_PROGRESS', 'BLOCKED', 'NA', 'CANCELLED']
if (status && !allowedStatuses.includes(status)) {
@ -51,6 +54,8 @@ export async function PATCH(
}
const updateData: any = {}
// Status changes
if (status !== undefined) {
updateData.status = status
if (status === 'COMPLETED') {
@ -64,15 +69,66 @@ export async function PATCH(
updateData.naReason = naReason.trim()
}
}
// Scalar field updates
if (title !== undefined) updateData.title = title
if (description !== undefined) updateData.description = description || null
if (priority !== undefined) updateData.priority = priority
if (department !== undefined) updateData.department = department
if (dueDate !== undefined) updateData.dueDate = new Date(dueDate)
if (notes !== undefined) updateData.notes = notes
if (imageRightFiled !== undefined) updateData.imageRightFiled = imageRightFiled
if (reminderDate !== undefined) updateData.reminderDate = reminderDate ? new Date(reminderDate) : null
// Association changes
if (clientId !== undefined) updateData.clientId = clientId
if (policyId !== undefined) updateData.policyId = policyId || null
if (policyGroupId !== undefined) updateData.policyGroupId = policyGroupId || null
const updated = await prisma.task.update({
where: { id },
data: updateData,
include: {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
policyGroup: { select: { id: true, name: true, renewalDate: true } },
assignments: { include: { user: { select: { id: true, displayName: true, email: true } } } },
},
})
// Reassign users if provided
if (Array.isArray(assignedUserIds)) {
await prisma.taskAssignment.deleteMany({ where: { taskId: id } })
if (assignedUserIds.length > 0) {
await prisma.taskAssignment.createMany({
data: assignedUserIds.map((uid: string) => ({ taskId: id, userId: uid })),
})
}
// Re-fetch with updated assignments
const refreshed = await prisma.task.findUnique({
where: { id },
include: {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
policyGroup: { select: { id: true, name: true, renewalDate: true } },
assignments: { include: { user: { select: { id: true, displayName: true, email: true } } } },
},
})
await prisma.auditLog.create({
data: {
userId,
action: 'TASK_UPDATED',
entityType: 'Task',
entityId: id,
oldValues: { status: task.status, notes: task.notes },
newValues: { ...updateData, assignedUserIds },
},
})
return NextResponse.json(refreshed)
}
await prisma.auditLog.create({
data: {
userId,

View file

@ -25,6 +25,7 @@ import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
import { formatDate, formatRenewalDate } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
import { toast } from 'sonner'
interface SimpleUser {
@ -68,6 +69,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
const [showCompleted, setShowCompleted] = useState(false)
const [notesExpanded, setNotesExpanded] = useState(!!(client.notes))
const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
const [editingTaskId, setEditingTaskId] = useState<string | null>(null)
const [sessionUserId, setSessionUserId] = useState('')
useEffect(() => {
@ -711,7 +713,15 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
</p>
)}
</div>
<div className="text-right shrink-0 ml-4">
<div className="flex flex-col items-end gap-1 shrink-0 ml-4">
<button
type="button"
onClick={() => setEditingTaskId(task.id)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded px-1.5 py-0.5 transition-colors"
title="Edit task"
>
<Pencil className="h-3 w-3" />
</button>
<p className={`text-sm font-medium ${overdue ? 'text-red-500' : ''}`} suppressHydrationWarning>
Due: {formatDate(task.dueDate)}
</p>
@ -723,6 +733,27 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
})
)
})()}
{/* Task edit modal */}
{editingTaskId && (() => {
const editTask = tasks.find((t: any) => t.id === editingTaskId)
if (!editTask) return null
return (
<TaskEditModal
open={!!editingTaskId}
onOpenChange={(open) => { if (!open) setEditingTaskId(null) }}
task={{
...editTask,
clientId: client.id,
client: { id: client.id, name: client.name },
} as EditableTask}
onUpdated={() => {
setEditingTaskId(null)
refreshTasks()
}}
/>
)
})()}
</TabsContent>
<AdditionalServiceModal

View file

@ -0,0 +1,393 @@
'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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { UserSelectContent } from '@/components/ui/user-select-content'
import { Pencil } 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
}
export interface EditableTask {
id: string
title: string
description: string | null
priority: string
department: string
dueDate: string | Date
status: string
clientId: string
policyId?: string | null
policyGroupId?: string | null
client?: { id: string; name: string } | null
policy?: { id: string; policyNumber: string | null; policyType: string | null; expirationDate?: string | Date | null } | null
policyGroup?: { id: string; name: string | null; renewalDate?: string | Date | null } | null
assignments: Array<{ id?: string; user: { id?: string; displayName: string | null; email: string }; userId?: string }>
}
interface TaskEditModalProps {
open: boolean
onOpenChange: (open: boolean) => void
task: EditableTask
onUpdated: (updated: any) => void
}
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']
const DEPARTMENTS = [
{ value: 'CLAIMS', label: 'Claims' },
{ value: 'PERSONAL_LINES', label: 'Personal Lines' },
{ value: 'COMMERCIAL_LINES', label: 'Commercial Lines' },
{ value: 'BENEFITS', label: 'Benefits' },
{ value: 'OTHER', label: 'Other' },
]
export function TaskEditModal({ open, onOpenChange, task, onUpdated }: TaskEditModalProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [priority, setPriority] = useState('MEDIUM')
const [department, setDepartment] = useState('CLAIMS')
const [dueDate, setDueDate] = useState('')
const [assignTo, setAssignTo] = useState('')
const [level, setLevel] = useState<'client' | 'policy' | 'group'>('client')
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<SimpleUser[]>([])
// Client search
const [clientSearch, setClientSearch] = useState('')
const [clientId, setClientId] = useState('')
const [clientOptions, setClientOptions] = useState<{ id: string; name: string }[]>([])
const [clientDropOpen, setClientDropOpen] = useState(false)
// Policy/group lists for the selected client
const [policies, setPolicies] = useState<PolicyOption[]>([])
const [groups, setGroups] = useState<PolicyGroupOption[]>([])
const [selectedPolicyId, setSelectedPolicyId] = useState('')
const [selectedGroupId, setSelectedGroupId] = useState('')
// Populate form from task when modal opens
useEffect(() => {
if (!open) return
setTitle(task.title)
setDescription(task.description ?? '')
setPriority(task.priority)
setDepartment(task.department)
setDueDate(new Date(task.dueDate).toISOString().split('T')[0])
setClientId(task.clientId || task.client?.id || '')
setClientSearch(task.client?.name || '')
const firstAssigneeId = task.assignments?.[0]?.user?.id || task.assignments?.[0]?.userId || ''
setAssignTo(firstAssigneeId as string)
if (task.policyGroupId || task.policyGroup) {
setLevel('group')
setSelectedGroupId(task.policyGroupId || task.policyGroup?.id || '')
setSelectedPolicyId('')
} else if (task.policyId || task.policy) {
setLevel('policy')
setSelectedPolicyId(task.policyId || task.policy?.id || '')
setSelectedGroupId('')
} else {
setLevel('client')
setSelectedPolicyId('')
setSelectedGroupId('')
}
fetch('/api/users?isActive=true&limit=200')
.then((r) => r.json())
.then((d) => setUsers(d.users || []))
.catch(() => {})
// Fetch policies/groups for the client
const cid = task.clientId || task.client?.id
if (cid) fetchClientData(cid)
}, [open])
async function fetchClientData(cid: string) {
try {
const [clientRes, groupsRes] = await Promise.all([
fetch(`/api/clients/${cid}`),
fetch(`/api/clients/${cid}/policy-groups`),
])
if (clientRes.ok) {
const cd = await clientRes.json()
const pols = (cd.policies || []).map((p: any) => ({
id: p.id,
policyNumber: p.policyNumber,
policyType: p.policyType,
}))
setPolicies(pols)
}
if (groupsRes.ok) {
const gd = await groupsRes.json()
const grps = (Array.isArray(gd) ? gd : gd.groups || []).map((g: any) => ({
id: g.id,
name: g.name,
renewalDate: g.renewalDate,
}))
setGroups(grps)
}
} catch {}
}
// Client search debounce
useEffect(() => {
if (!clientSearch.trim() || clientId) { 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, clientId])
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(),
clientId,
}
if (level === 'policy' && selectedPolicyId) {
body.policyId = selectedPolicyId
body.policyGroupId = null
} else if (level === 'group' && selectedGroupId) {
body.policyGroupId = selectedGroupId
body.policyId = null
} else {
body.policyId = null
body.policyGroupId = null
}
if (assignTo) body.assignedUserIds = [assignTo]
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
toast.success('Task updated')
onUpdated(data)
onOpenChange(false)
} catch (err: any) {
toast.error(err.message || 'Failed to update task')
} finally {
setSaving(false)
}
}
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">
<Pencil className="h-5 w-5" />
Edit Task
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Title */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Title *</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</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)} rows={2} className="text-sm" />
</div>
{/* Client */}
<div className="space-y-1.5 relative">
<label className="text-sm font-medium">Client *</label>
<Input
placeholder="Search client..."
value={clientSearch}
onChange={(e) => { setClientSearch(e.target.value); setClientId(''); setClientDropOpen(true) }}
readOnly={!!clientId}
/>
{clientId && (
<button
onClick={() => { setClientId(''); setClientSearch(''); setPolicies([]); setGroups([]) }}
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)
fetchClientData(c.id)
}}
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' && 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>
{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' && groups.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>
{groups.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} value={d.value}>{d.label}</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 placeholder="Select user..." /></SelectTrigger>
<UserSelectContent users={users} />
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving} className="gap-1.5">
<Pencil className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}