Task card carrier/writing co + group popover; fix Renews date; filter 0-policy setup queue; login tagline; advocate reassign dialog

This commit is contained in:
lorentz 2026-04-13 17:42:36 +00:00
parent 384821de33
commit a6fa5d12ea
10 changed files with 258 additions and 30 deletions

View file

@ -33,6 +33,7 @@ export default async function SetupQueuePage() {
const clients = await prisma.client.findMany({
where: {
designation: { name: { in: ['Shape', 'Shape 2'] } },
policies: { some: {} },
OR: [
{ claimsAdvocateId: null },
{ setupCompletedAt: null },

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -21,7 +21,7 @@ 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, Pencil } from 'lucide-react'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter, Pencil, ChevronDown } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Input } from '@/components/ui/input'
import { formatDate, formatRenewalDate } from '@/lib/utils'
@ -43,7 +43,7 @@ interface Task {
dueDate: string | Date
notes: string | null
client: { id: string; name: string } | null
policy: { id: string; policyNumber: string | null; policyType: string | null; expirationDate: string | Date } | null
policy: { id: string; policyNumber: string | null; policyType: string | null; expirationDate: string | Date; carrierName: string | null; writingCompanyName: string | null } | null
policyGroup: { id: string; name: string | null; renewalDate: string | Date | null } | null
assignments: TaskAssignment[]
taskNotes: TaskNote[]
@ -100,6 +100,10 @@ function TaskCard({ task: initial }: { task: Task }) {
const [naSubmitting, setNaSubmitting] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [statusUpdating, setStatusUpdating] = useState(false)
const [groupPoliciesOpen, setGroupPoliciesOpen] = useState(false)
const [groupPolicies, setGroupPolicies] = useState<any[]>([])
const [groupPoliciesLoading, setGroupPoliciesLoading] = useState(false)
const groupPopoverRef = useRef<HTMLDivElement>(null)
const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
const [reminderDate, setReminderDate] = useState('')
@ -203,6 +207,22 @@ function TaskCard({ task: initial }: { task: Task }) {
const levelLabel = task.policyGroup ? 'Group' : task.policy ? 'Policy' : 'Client'
const handleGroupClick = async () => {
if (groupPoliciesOpen) { setGroupPoliciesOpen(false); return }
if (groupPolicies.length === 0 && task.policyGroup?.id) {
setGroupPoliciesLoading(true)
try {
const res = await fetch(`/api/policy-groups/${task.policyGroup.id}`)
if (res.ok) {
const data = await res.json()
setGroupPolicies(data.policies || [])
}
} catch {}
finally { setGroupPoliciesLoading(false) }
}
setGroupPoliciesOpen(true)
}
const handleQuickStatus = async (newStatus: string) => {
if (newStatus === task.status) return
if (newStatus === 'NA') { setNaDialogOpen(true); return }
@ -271,6 +291,52 @@ function TaskCard({ task: initial }: { task: Task }) {
<span><span className="text-muted-foreground">Type</span> <span className="text-foreground font-medium">{task.policy?.policyType || '—'}</span></span>
<span><span className="text-muted-foreground">Renewal</span> <span className="text-foreground font-medium" suppressHydrationWarning>{task.policyGroup?.renewalDate ? formatDate(task.policyGroup.renewalDate) : task.policy?.expirationDate ? formatRenewalDate(task.policy.expirationDate) : '—'}</span></span>
</div>
{task.policyGroup && (
<div className="relative" ref={groupPopoverRef}>
<button
onClick={handleGroupClick}
className="flex items-center gap-1 text-[13px] font-medium text-foreground hover:underline"
>
<span className="text-muted-foreground">Group</span>&nbsp;{task.policyGroup.name || 'Unnamed Group'}
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${groupPoliciesOpen ? 'rotate-180' : ''}`} />
</button>
{groupPoliciesOpen && (
<div className="absolute z-50 left-0 top-full mt-1 w-80 rounded-md border bg-popover shadow-md text-xs">
<div className="px-3 py-2 border-b font-semibold text-sm flex items-center justify-between">
<span>Policies in group</span>
<button onClick={() => setGroupPoliciesOpen(false)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
</div>
{groupPoliciesLoading ? (
<div className="px-3 py-3 text-muted-foreground">Loading...</div>
) : groupPolicies.length === 0 ? (
<div className="px-3 py-3 text-muted-foreground">No policies in this group.</div>
) : (
<div className="divide-y">
{groupPolicies.map((p: any) => (
<div key={p.id} className="px-3 py-2 space-y-0.5">
<div className="font-medium">{p.policyNumber || '—'} <span className="text-muted-foreground font-normal">· {p.policyType || '—'}</span></div>
<div className="text-muted-foreground">
{p.carrierName || 'Unknown carrier'}
{p.expirationDate && <span suppressHydrationWarning> · Renews {formatRenewalDate(p.expirationDate)}</span>}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{(task.policy?.carrierName || task.policy?.writingCompanyName) && (
<div className="flex flex-wrap gap-x-5 gap-y-0.5 text-[13px]">
{task.policy?.carrierName && (
<span><span className="text-muted-foreground">Carrier</span> <span className="text-foreground font-medium">{task.policy.carrierName}</span></span>
)}
{task.policy?.writingCompanyName && (
<span><span className="text-muted-foreground">Writing Co</span> <span className="text-foreground font-medium">{task.policy.writingCompanyName}</span></span>
)}
</div>
)}
{task.assignments.length > 0 && (
<p className="text-[12px] text-muted-foreground">
Assigned to {task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')}

View file

@ -51,7 +51,7 @@ export default async function TasksPage() {
where: activeTaskWhere,
include: {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true, carrierName: true, writingCompanyName: true } },
policyGroup: { select: { id: true, name: true, renewalDate: true } },
assignments: {
include: { user: { select: { displayName: true, email: true } } },

View file

@ -114,7 +114,7 @@ export async function PATCH(
const { id } = await params
const body = await request.json()
const { designationId, designation2Id, claimsAdvocateId, notes, customFields, parentClientId } = body
const { designationId, designation2Id, claimsAdvocateId, notes, customFields, parentClientId, reassignTasks } = body
const existing = await prisma.client.findUnique({
where: { id },
@ -150,7 +150,10 @@ export async function PATCH(
}),
]
if (claimsAdvocateId !== undefined && claimsAdvocateId !== existing?.claimsAdvocateId) {
const advocateChanged =
claimsAdvocateId !== undefined && claimsAdvocateId !== existing?.claimsAdvocateId
if (advocateChanged) {
auditEntries.push(
prisma.auditLog.create({
data: {
@ -165,6 +168,45 @@ export async function PATCH(
)
}
// Reassign open tasks from old advocate to new advocate if requested
if (advocateChanged && reassignTasks && existing?.claimsAdvocateId && claimsAdvocateId) {
const openStatuses = ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] as any[]
const openTasks = await prisma.task.findMany({
where: {
clientId: id,
status: { in: openStatuses },
assignments: { some: { userId: existing.claimsAdvocateId } },
},
select: { id: true },
})
if (openTasks.length > 0) {
const taskIds = openTasks.map((t) => t.id)
// Remove old advocate assignments and add new advocate assignments
await prisma.taskAssignment.deleteMany({
where: { taskId: { in: taskIds }, userId: existing.claimsAdvocateId },
})
await prisma.taskAssignment.createMany({
data: taskIds.map((taskId) => ({ taskId, userId: claimsAdvocateId })),
skipDuplicates: true,
})
auditEntries.push(
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'ADVOCATE_TASK_REASSIGNMENT',
entityType: 'Client',
entityId: client.id,
oldValues: { fromAdvocateId: existing.claimsAdvocateId },
newValues: { toAdvocateId: claimsAdvocateId, taskCount: openTasks.length, taskIds },
},
})
)
}
}
await Promise.all(auditEntries)
return NextResponse.json(client)

View file

@ -17,6 +17,7 @@ interface SetupPayload {
claimsAdvocateId: string
clientNotes?: string | null
isDraft?: boolean
reassignTasks?: boolean
}
export async function POST(
@ -53,6 +54,12 @@ async function handleSave(
const body: SetupPayload = await request.json()
const isDraft = isDraftOverride || body.isDraft === true
const existingClient = await prisma.client.findUnique({
where: { id: clientId },
select: { claimsAdvocateId: true },
})
const previousAdvocateId = existingClient?.claimsAdvocateId
const defaultGroup = body.groups.find((g) => g.isDefault) ?? body.groups[0]
const defaultRenewalDate = defaultGroup
? new Date(defaultGroup.renewalDate)
@ -126,15 +133,56 @@ async function handleSave(
select: { id: true, name: true, renewalDate: true, setupCompletedAt: true, claimsAdvocateId: true },
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: isDraft ? 'CLIENT_SETUP_DRAFT' : 'CLIENT_SETUP_COMPLETED',
entityType: 'Client',
entityId: clientId,
newValues: { groupCount: body.groups.length, isDraft },
},
})
const advocateChanged = body.claimsAdvocateId && body.claimsAdvocateId !== previousAdvocateId
const auditEntries: Promise<any>[] = [
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: isDraft ? 'CLIENT_SETUP_DRAFT' : 'CLIENT_SETUP_COMPLETED',
entityType: 'Client',
entityId: clientId,
newValues: { groupCount: body.groups.length, isDraft },
},
}),
]
if (advocateChanged && body.reassignTasks && previousAdvocateId && body.claimsAdvocateId) {
const openStatuses = ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] as any[]
const openTasks = await prisma.task.findMany({
where: {
clientId,
status: { in: openStatuses },
assignments: { some: { userId: previousAdvocateId } },
},
select: { id: true },
})
if (openTasks.length > 0) {
const taskIds = openTasks.map((t) => t.id)
await prisma.taskAssignment.deleteMany({
where: { taskId: { in: taskIds }, userId: previousAdvocateId },
})
await prisma.taskAssignment.createMany({
data: taskIds.map((taskId) => ({ taskId, userId: body.claimsAdvocateId! })),
skipDuplicates: true,
})
auditEntries.push(
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'ADVOCATE_TASK_REASSIGNMENT',
entityType: 'Client',
entityId: clientId,
oldValues: { fromAdvocateId: previousAdvocateId },
newValues: { toAdvocateId: body.claimsAdvocateId, taskCount: openTasks.length, taskIds },
},
})
)
}
}
await Promise.all(auditEntries)
return NextResponse.json({ client: updated })
} catch (error) {

View file

@ -12,7 +12,7 @@ export default function SignInPage() {
</div>
<div>
<h2 className="text-4xl font-bold text-white leading-tight">
Policy Renewal<br />Workflow Management
Shape Process
</h2>
<p className="mt-4 text-white/70 text-lg">
Horizon keeps your team on top of every renewal, every client, every deadline.

View file

@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Horizon - Policy Renewal Workflow Management",
title: "Horizon - Shape Process",
description: "Streamline your insurance policy renewal workflow with Horizon",
icons: {
icon: "/favicon.png",

View file

@ -12,6 +12,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
@ -101,6 +111,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
const [claimsUsers, setClaimsUsers] = useState<SimpleUser[]>([])
const [advocateId, setAdvocateId] = useState<string>(client.claimsAdvocate?.id || '')
const [advocateSaving, setAdvocateSaving] = useState(false)
const [reassignDialogOpen, setReassignDialogOpen] = useState(false)
const [members, setMembers] = useState<any[]>(client.members || [])
const [addMemberId, setAddMemberId] = useState<string>('')
@ -115,16 +126,16 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
.catch(() => {})
}, [])
const handleAdvocateSave = async () => {
const doAdvocateSave = async (reassignTasks: boolean) => {
setAdvocateSaving(true)
try {
const res = await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claimsAdvocateId: advocateId || null }),
body: JSON.stringify({ claimsAdvocateId: advocateId || null, reassignTasks }),
})
if (!res.ok) throw new Error()
toast.success('Claims advocate updated')
toast.success(reassignTasks ? 'Advocate updated and tasks reassigned' : 'Claims advocate updated')
} catch {
toast.error('Failed to update advocate')
} finally {
@ -132,6 +143,16 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
}
}
const handleAdvocateSave = () => {
const previousAdvocateId = client.claimsAdvocate?.id
const isReplacing = !!previousAdvocateId && advocateId !== previousAdvocateId && !!advocateId
if (isReplacing) {
setReassignDialogOpen(true)
} else {
doAdvocateSave(false)
}
}
const handleAddMember = async () => {
if (!addMemberId) return
try {
@ -1125,6 +1146,26 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
</Card>
</TabsContent>
</Tabs>
{/* Advocate reassignment dialog */}
<AlertDialog open={reassignDialogOpen} onOpenChange={setReassignDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reassign open tasks?</AlertDialogTitle>
<AlertDialogDescription>
Do you want to reassign all open tasks currently assigned to the previous advocate to the new advocate?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => { setReassignDialogOpen(false); doAdvocateSave(false) }}>
No, change advocate only
</AlertDialogCancel>
<AlertDialogAction onClick={() => { setReassignDialogOpen(false); doAdvocateSave(true) }}>
Yes, reassign tasks too
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -4,6 +4,7 @@ import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { AlertTriangle, GripVertical } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { formatRenewalDate } from '@/lib/utils'
export interface PolicyChipData {
id: string
@ -33,11 +34,7 @@ export function PolicyChip({ policy, disabled }: PolicyChipProps) {
}
const expDisplay = policy.expirationDate
? new Date(policy.expirationDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
? formatRenewalDate(policy.expirationDate)
: null
return (

View file

@ -189,6 +189,8 @@ export function SetupWizard({
const [activePolicy, setActivePolicy] = useState<PolicyChipData | null>(null)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [reassignDialogOpen, setReassignDialogOpen] = useState(false)
const [pendingSaveDraft, setPendingSaveDraft] = useState<boolean | null>(null)
useEffect(() => {
if (!initializedRef.current) {
@ -354,11 +356,7 @@ export function SetupWizard({
setWizardDirty(true)
}
async function handleSave(draft: boolean) {
if (!draft && !advocateId) {
toast.error('Please select a Claims Advocate before saving.')
return
}
async function doSave(draft: boolean, reassignTasks: boolean) {
setSaving(true)
try {
const payload = {
@ -373,6 +371,7 @@ export function SetupWizard({
claimsAdvocateId: advocateId,
clientNotes: clientNotes || null,
isDraft: draft,
reassignTasks,
}
const method = draft ? 'PUT' : 'POST'
@ -398,6 +397,20 @@ export function SetupWizard({
}
}
function handleSave(draft: boolean) {
if (!draft && !advocateId) {
toast.error('Please select a Claims Advocate before saving.')
return
}
const isReplacing = !!initialAdvocateId && advocateId !== initialAdvocateId && !!advocateId
if (isReplacing) {
setPendingSaveDraft(draft)
setReassignDialogOpen(true)
} else {
doSave(draft, false)
}
}
const canFinish = !!advocateId && groups.length > 0
return (
@ -596,6 +609,26 @@ export function SetupWizard({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Advocate reassignment dialog */}
<AlertDialog open={reassignDialogOpen} onOpenChange={setReassignDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reassign open tasks?</AlertDialogTitle>
<AlertDialogDescription>
Do you want to reassign all open tasks currently assigned to the previous advocate to the new advocate?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => { setReassignDialogOpen(false); doSave(pendingSaveDraft ?? false, false) }}>
No, change advocate only
</AlertDialogCancel>
<AlertDialogAction onClick={() => { setReassignDialogOpen(false); doSave(pendingSaveDraft ?? false, true) }}>
Yes, reassign tasks too
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}