diff --git a/ondeck/src/app/(dashboard)/manager/setup/page.tsx b/ondeck/src/app/(dashboard)/manager/setup/page.tsx index ad744d3..9030d9d 100644 --- a/ondeck/src/app/(dashboard)/manager/setup/page.tsx +++ b/ondeck/src/app/(dashboard)/manager/setup/page.tsx @@ -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 }, diff --git a/ondeck/src/app/(dashboard)/tasks/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/page-client.tsx index 5f02674..27c08ce 100644 --- a/ondeck/src/app/(dashboard)/tasks/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page-client.tsx @@ -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([]) + const [groupPoliciesLoading, setGroupPoliciesLoading] = useState(false) + const groupPopoverRef = useRef(null) const [completeDialogOpen, setCompleteDialogOpen] = useState(false) const [imageRightFiled, setImageRightFiled] = useState(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 }) { Type {task.policy?.policyType || '—'} Renewal {task.policyGroup?.renewalDate ? formatDate(task.policyGroup.renewalDate) : task.policy?.expirationDate ? formatRenewalDate(task.policy.expirationDate) : '—'} + {task.policyGroup && ( +
+ + {groupPoliciesOpen && ( +
+
+ Policies in group + +
+ {groupPoliciesLoading ? ( +
Loading...
+ ) : groupPolicies.length === 0 ? ( +
No policies in this group.
+ ) : ( +
+ {groupPolicies.map((p: any) => ( +
+
{p.policyNumber || '—'} · {p.policyType || '—'}
+
+ {p.carrierName || 'Unknown carrier'} + {p.expirationDate && · Renews {formatRenewalDate(p.expirationDate)}} +
+
+ ))} +
+ )} +
+ )} +
+ )} + {(task.policy?.carrierName || task.policy?.writingCompanyName) && ( +
+ {task.policy?.carrierName && ( + Carrier {task.policy.carrierName} + )} + {task.policy?.writingCompanyName && ( + Writing Co {task.policy.writingCompanyName} + )} +
+ )} {task.assignments.length > 0 && (

Assigned to {task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')} diff --git a/ondeck/src/app/(dashboard)/tasks/page.tsx b/ondeck/src/app/(dashboard)/tasks/page.tsx index cff7a0f..3e4a02c 100644 --- a/ondeck/src/app/(dashboard)/tasks/page.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page.tsx @@ -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 } } }, diff --git a/ondeck/src/app/api/clients/[id]/route.ts b/ondeck/src/app/api/clients/[id]/route.ts index d8f8a15..ae6567a 100644 --- a/ondeck/src/app/api/clients/[id]/route.ts +++ b/ondeck/src/app/api/clients/[id]/route.ts @@ -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) diff --git a/ondeck/src/app/api/clients/[id]/setup/route.ts b/ondeck/src/app/api/clients/[id]/setup/route.ts index 92ff9b0..7a4e4a2 100644 --- a/ondeck/src/app/api/clients/[id]/setup/route.ts +++ b/ondeck/src/app/api/clients/[id]/setup/route.ts @@ -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[] = [ + 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) { diff --git a/ondeck/src/app/auth/signin/page.tsx b/ondeck/src/app/auth/signin/page.tsx index be09ef0..070d1ab 100644 --- a/ondeck/src/app/auth/signin/page.tsx +++ b/ondeck/src/app/auth/signin/page.tsx @@ -12,7 +12,7 @@ export default function SignInPage() {

- Policy Renewal
Workflow Management + Shape Process

Horizon keeps your team on top of every renewal, every client, every deadline. diff --git a/ondeck/src/app/layout.tsx b/ondeck/src/app/layout.tsx index 85c0bcf..2f707f5 100644 --- a/ondeck/src/app/layout.tsx +++ b/ondeck/src/app/layout.tsx @@ -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", diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index d3075d9..c70b2ca 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -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([]) const [advocateId, setAdvocateId] = useState(client.claimsAdvocate?.id || '') const [advocateSaving, setAdvocateSaving] = useState(false) + const [reassignDialogOpen, setReassignDialogOpen] = useState(false) const [members, setMembers] = useState(client.members || []) const [addMemberId, setAddMemberId] = useState('') @@ -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 + + {/* Advocate reassignment dialog */} + + + + Reassign open tasks? + + Do you want to reassign all open tasks currently assigned to the previous advocate to the new advocate? + + + + { setReassignDialogOpen(false); doAdvocateSave(false) }}> + No, change advocate only + + { setReassignDialogOpen(false); doAdvocateSave(true) }}> + Yes, reassign tasks too + + + +

) } diff --git a/ondeck/src/components/renewal-groups/policy-chip.tsx b/ondeck/src/components/renewal-groups/policy-chip.tsx index 7a65fee..1f59ba0 100644 --- a/ondeck/src/components/renewal-groups/policy-chip.tsx +++ b/ondeck/src/components/renewal-groups/policy-chip.tsx @@ -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 ( diff --git a/ondeck/src/components/renewal-groups/setup-wizard.tsx b/ondeck/src/components/renewal-groups/setup-wizard.tsx index 75e1805..44999c2 100644 --- a/ondeck/src/components/renewal-groups/setup-wizard.tsx +++ b/ondeck/src/components/renewal-groups/setup-wizard.tsx @@ -189,6 +189,8 @@ export function SetupWizard({ const [activePolicy, setActivePolicy] = useState(null) const [rerunDialogOpen, setRerunDialogOpen] = useState(false) const [saving, setSaving] = useState(false) + const [reassignDialogOpen, setReassignDialogOpen] = useState(false) + const [pendingSaveDraft, setPendingSaveDraft] = useState(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({ + + {/* Advocate reassignment dialog */} + + + + Reassign open tasks? + + Do you want to reassign all open tasks currently assigned to the previous advocate to the new advocate? + + + + { setReassignDialogOpen(false); doSave(pendingSaveDraft ?? false, false) }}> + No, change advocate only + + { setReassignDialogOpen(false); doSave(pendingSaveDraft ?? false, true) }}> + Yes, reassign tasks too + + + + ) }