Task system improvements batch

- Fix due date off-by-one: parse date-only strings as local time in formatDate/formatRenewalDate
- Additional Services: dynamic fetch of policies/groups when no context provided
- Template sync: admin button + POST /api/templates/[id]/sync endpoint
- Policy<>Group movement: confirmation dialog, cancel-policy-tasks, generate-policy-tasks APIs
- Client-level tasks: show anchor group renewal date with star in TaskCard
- Policy group manager: show star on anchor (earliest renewal) group
This commit is contained in:
lorentz 2026-04-24 18:26:43 +00:00
parent 9331618ba6
commit 8b43a25f4d
10 changed files with 563 additions and 17 deletions

View file

@ -75,5 +75,21 @@ export async function GET(
orderBy: { dueDate: 'asc' },
})
return NextResponse.json(tasks)
// For client-level tasks (no policy, no group), attach the anchor group (earliest renewalDate)
const hasClientLevelTasks = tasks.some((t) => !t.policyId && !t.policyGroupId)
let anchorGroup: { id: string; name: string; renewalDate: Date } | null = null
if (hasClientLevelTasks) {
anchorGroup = await prisma.policyGroup.findFirst({
where: { clientId: id },
orderBy: { renewalDate: 'asc' },
select: { id: true, name: true, renewalDate: true },
})
}
const enriched = tasks.map((t) => ({
...t,
anchorGroup: (!t.policyId && !t.policyGroupId) ? anchorGroup : null,
}))
return NextResponse.json(enriched)
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* GET /api/policies/[id]/open-task-count
* Returns the count of open template-generated tasks for a policy.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const count = await prisma.task.count({
where: {
policyId: id,
templateId: { not: null },
status: { in: ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] },
},
})
return NextResponse.json({ count })
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/policy-groups/[id]/cancel-policy-tasks
* Cancels open template-generated policy-level tasks for specified policyIds
* (called when policies are being moved into a group).
* Body: { policyIds: string[] }
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { policyIds } = await request.json()
if (!Array.isArray(policyIds) || policyIds.length === 0) {
return NextResponse.json({ cancelled: 0 })
}
const result = await prisma.task.updateMany({
where: {
policyId: { in: policyIds },
templateId: { not: null },
status: { in: ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] },
},
data: {
status: 'CANCELLED',
cancelledReason: 'Policy moved into renewal group',
},
})
return NextResponse.json({ cancelled: result.count })
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/policy-groups/[id]/generate-policy-tasks
* Regenerate individual policy-level tasks for a policy that was removed from this group.
* Body: { policyId: string }
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { policyId } = await request.json()
if (!policyId) {
return NextResponse.json({ error: 'policyId is required' }, { status: 400 })
}
const policy = await prisma.policy.findUnique({
where: { id: policyId },
include: {
client: {
select: {
designationId: true,
designation2Id: true,
claimsAdvocateId: true,
},
},
},
})
if (!policy) {
return NextResponse.json({ error: 'Policy not found' }, { status: 404 })
}
const designationIds = [
policy.client.designationId,
policy.client.designation2Id,
].filter(Boolean) as string[]
const templates = await prisma.taskTemplate.findMany({
where: {
isActive: true,
level: { in: ['BOTH', 'POLICY'] },
OR: [
{ designationId: null },
...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
],
},
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
})
if (templates.length === 0) {
return NextResponse.json({ created: 0, message: 'No templates found' })
}
const anchorDate = new Date(policy.expirationDate)
anchorDate.setDate(anchorDate.getDate() + 1) // renewal = expiry + 1
const tasksToCreate = templates
.filter((t) => !t.policyTypeFilter || t.policyTypeFilter === policy.policyType)
.map((template) => {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: policy.clientId,
policyId: policy.id,
templateId: template.id,
createdBy: (session.user as any).id,
}
})
if (tasksToCreate.length === 0) {
return NextResponse.json({ created: 0 })
}
const result = await prisma.task.createMany({ data: tasksToCreate })
// Auto-assign to claims advocate
if (policy.client.claimsAdvocateId && result.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyId: policy.id, templateId: { in: templates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({ taskId: t.id, userId: policy.client.claimsAdvocateId! })),
skipDuplicates: true,
})
}
}
return NextResponse.json({ created: result.count })
} catch (error) {
console.error('Generate policy tasks error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/templates/[id]/sync
* Propagate template changes (title, description, department, priority, daysOffset)
* to all open tasks generated from this template.
* Due dates are recalculated only if daysOffset changed.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'templates.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const template = await prisma.taskTemplate.findUnique({ where: { id } })
if (!template) {
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Find all open tasks from this template
const openTasks = await prisma.task.findMany({
where: {
templateId: id,
status: { in: ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] },
},
select: {
id: true,
daysOffset: true,
policyGroupId: true,
policyId: true,
policy: { select: { expirationDate: true } },
policyGroup: { select: { renewalDate: true } },
},
})
if (openTasks.length === 0) {
return NextResponse.json({ updated: 0, message: 'No open tasks to update' })
}
const daysOffsetChanged = template.daysOffset !== openTasks[0]?.daysOffset
let updated = 0
for (const task of openTasks) {
const patch: any = {
title: template.name,
description: template.description,
department: template.department,
priority: template.defaultPriority,
daysOffset: template.daysOffset,
}
// Recalculate due date if daysOffset changed
if (daysOffsetChanged) {
let anchorDate: Date | null = null
if (task.policyGroup?.renewalDate) {
anchorDate = new Date(task.policyGroup.renewalDate)
} else if (task.policy?.expirationDate) {
anchorDate = new Date(task.policy.expirationDate)
anchorDate.setDate(anchorDate.getDate() + 1) // renewal = expiry + 1
}
if (anchorDate) {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
patch.dueDate = dueDate
}
}
await prisma.task.update({ where: { id: task.id }, data: patch })
updated++
}
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'SYNC_TEMPLATE_TO_TASKS',
entityType: 'TaskTemplate',
entityId: id,
newValues: { updated, templateName: template.name },
},
})
return NextResponse.json({ updated, message: `${updated} open task(s) updated` })
} catch (error) {
console.error('Template sync error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* GET /api/templates/[id]/sync
* Preview how many open tasks would be affected.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const count = await prisma.task.count({
where: {
templateId: id,
status: { in: ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] },
},
})
return NextResponse.json({ openTaskCount: count })
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -11,6 +11,7 @@ import {
Calendar,
Building2,
Tag,
RefreshCw,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -32,6 +33,16 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
@ -151,6 +162,9 @@ export function TaskTemplateManager({
const [formData, setFormData] = useState(emptyTemplate)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [syncConfirmId, setSyncConfirmId] = useState<string | null>(null)
const [syncCount, setSyncCount] = useState<number | null>(null)
const [syncing, setSyncing] = useState(false)
const filteredTemplates = templates.filter((template) => {
const matchesSearch =
@ -245,6 +259,34 @@ export function TaskTemplateManager({
}
}
const handleSyncPreview = async (id: string) => {
try {
const res = await fetch(`/api/templates/${id}/sync`)
const data = await res.json()
setSyncCount(data.openTaskCount ?? 0)
setSyncConfirmId(id)
} catch {
toast.error('Failed to check task count')
}
}
const handleSyncConfirm = async () => {
if (!syncConfirmId) return
setSyncing(true)
try {
const res = await fetch(`/api/templates/${syncConfirmId}/sync`, { method: 'POST' })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
toast.success(data.message)
setSyncConfirmId(null)
setSyncCount(null)
} catch (err: any) {
toast.error(err.message || 'Sync failed')
} finally {
setSyncing(false)
}
}
const handleDelete = async (id: string) => {
try {
const response = await fetch(`/api/templates/${id}`, {
@ -703,9 +745,18 @@ export function TaskTemplateManager({
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(template)}
title="Edit template"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleSyncPreview(template.id)}
title="Sync changes to existing open tasks"
>
<RefreshCw className="h-4 w-4 text-blue-500" />
</Button>
{deleteConfirmId === template.id ? (
<div className="flex items-center gap-1">
<Button
@ -741,6 +792,26 @@ export function TaskTemplateManager({
)}
</CardContent>
</Card>
{/* Sync confirmation dialog */}
<AlertDialog open={!!syncConfirmId} onOpenChange={(open) => { if (!open) { setSyncConfirmId(null); setSyncCount(null) } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sync template to existing tasks?</AlertDialogTitle>
<AlertDialogDescription>
This will update the title, description, department, priority, and due date offset on{' '}
<strong>{syncCount ?? '...'} open task(s)</strong> generated from this template.
Completed, cancelled, and N/A tasks will not be affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSyncConfirm} disabled={syncing}>
{syncing ? 'Syncing…' : 'Sync Tasks'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -97,6 +97,11 @@ export function PolicyGroupManager({
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
const [adhocGroupId, setAdhocGroupId] = useState<string | null>(null)
const [sessionUserId, setSessionUserId] = useState('')
// Policy movement confirmation
const [policyMoveConfirm, setPolicyMoveConfirm] = useState<{
addingWithTasks: { policyId: string; policyNumber: string; taskCount: number }[]
removing: string[]
} | null>(null)
useEffect(() => {
fetch('/api/auth/session')
@ -145,16 +150,25 @@ export function PolicyGroupManager({
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name || !formData.renewalDate) {
toast.error('Name and renewal date are required')
return
}
const doSubmit = async () => {
setSubmitting(true)
setPolicyMoveConfirm(null)
try {
if (editingGroup) {
const removingIds = editingGroup.policies
.map((p) => p.id)
.filter((id) => !formData.policyIds.includes(id))
// Cancel open policy-level tasks for policies being added to the group
const addingIds = formData.policyIds.filter(
(id) => !editingGroup.policies.some((p) => p.id === id)
)
if (addingIds.length > 0) {
await fetch(`/api/policy-groups/${editingGroup.id}/cancel-policy-tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ policyIds: addingIds }),
})
}
const res = await fetch(`/api/policy-groups/${editingGroup.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@ -182,6 +196,14 @@ export function PolicyGroupManager({
}))
)
toast.success('Group updated')
// Regenerate tasks for removed (now ungrouped) policies
for (const pid of removingIds) {
await fetch(`/api/policy-groups/${editingGroup.id}/generate-policy-tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ policyId: pid }),
}).catch(() => {})
}
} else {
const res = await fetch(`/api/clients/${clientId}/policy-groups`, {
method: 'POST',
@ -217,6 +239,44 @@ export function PolicyGroupManager({
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name || !formData.renewalDate) {
toast.error('Name and renewal date are required')
return
}
// Check if any policies being added have open tasks
if (editingGroup) {
const addingIds = formData.policyIds.filter(
(id) => !editingGroup.policies.some((p) => p.id === id)
)
const removingIds = editingGroup.policies
.map((p) => p.id)
.filter((id) => !formData.policyIds.includes(id))
if (addingIds.length > 0 || removingIds.length > 0) {
const addingWithTasks: { policyId: string; policyNumber: string; taskCount: number }[] = []
for (const pid of addingIds) {
const res = await fetch(`/api/policies/${pid}/open-task-count`).catch(() => null)
if (res?.ok) {
const d = await res.json()
if (d.count > 0) {
const pol = policies.find((p) => p.id === pid)
addingWithTasks.push({ policyId: pid, policyNumber: pol?.policyNumber ?? pid, taskCount: d.count })
}
}
}
if (addingWithTasks.length > 0 || removingIds.length > 0) {
setPolicyMoveConfirm({ addingWithTasks, removing: removingIds })
return
}
}
}
await doSubmit()
}
const handleDelete = async () => {
if (!deleteTarget) return
try {
@ -301,8 +361,13 @@ export function PolicyGroupManager({
</Card>
) : (
<div className="space-y-3">
{groups.map((group) => {
{(() => {
const anchorGroupId = groups.length > 0
? groups.reduce((a, b) => new Date(a.renewalDate) <= new Date(b.renewalDate) ? a : b).id
: null
return groups.map((group) => {
const isExpanded = expandedGroupId === group.id
const isAnchor = group.id === anchorGroupId
return (
<Card key={group.id}>
<CardHeader className="pb-3">
@ -310,6 +375,9 @@ export function PolicyGroupManager({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base">{group.name}</CardTitle>
{isAnchor && (
<span title="Anchor group — used as renewal date for client-level tasks" className="text-yellow-500 text-sm leading-none"></span>
)}
<Badge variant="outline" className="text-xs">
<Calendar className="h-3 w-3 mr-1" />
<span suppressHydrationWarning>{formatDate(group.renewalDate)}</span>
@ -419,7 +487,8 @@ export function PolicyGroupManager({
)}
</Card>
)
})}
})
})()}
</div>
)}
@ -624,6 +693,40 @@ export function PolicyGroupManager({
/>
)
})()}
{/* Policy movement confirmation */}
<AlertDialog open={!!policyMoveConfirm} onOpenChange={(open) => { if (!open) setPolicyMoveConfirm(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm policy changes</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-sm text-muted-foreground">
{policyMoveConfirm?.addingWithTasks && policyMoveConfirm.addingWithTasks.length > 0 && (
<p>
The following policies have open tasks that will be <strong>cancelled</strong> and replaced with group-level tasks:
<ul className="mt-1 list-disc pl-4">
{policyMoveConfirm.addingWithTasks.map((p) => (
<li key={p.policyId}>{p.policyNumber} {p.taskCount} open task(s)</li>
))}
</ul>
</p>
)}
{policyMoveConfirm?.removing && policyMoveConfirm.removing.length > 0 && (
<p>
<strong>{policyMoveConfirm.removing.length}</strong> policy(ies) removed from the group will have individual policy-level tasks regenerated.
</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={doSubmit} disabled={submitting}>
{submitting ? 'Saving…' : 'Confirm & Save'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -104,6 +104,10 @@ export function AdditionalServiceModal({
const [selectedPolicyId, setSelectedPolicyId] = useState(context?.policyId ?? '')
const [selectedGroupId, setSelectedGroupId] = useState(context?.policyGroupId ?? '')
// Dynamically fetched when no context provided
const [fetchedPolicies, setFetchedPolicies] = useState<PolicyOption[]>([])
const [fetchedGroups, setFetchedGroups] = useState<PolicyGroupOption[]>([])
// Load users and recent ad hoc tasks when modal opens
useEffect(() => {
if (!open) return
@ -131,6 +135,22 @@ export function AdditionalServiceModal({
.catch(() => {})
}, [open])
// Fetch policies + groups when clientId is known but not provided via context
useEffect(() => {
if (!clientId || context?.clientId) { setFetchedPolicies([]); setFetchedGroups([]); return }
fetch(`/api/clients/${clientId}/policy-groups`)
.then((r) => r.json())
.then((d) => setFetchedGroups(Array.isArray(d) ? d.map((g: any) => ({ id: g.id, name: g.name, renewalDate: g.renewalDate })) : []))
.catch(() => {})
fetch(`/api/clients/${clientId}`)
.then((r) => r.json())
.then((d) => setFetchedPolicies((d.policies || []).map((p: any) => ({ id: p.id, policyNumber: p.policyNumber, policyType: p.policyType }))))
.catch(() => {})
}, [clientId, context?.clientId])
const availablePolicies = context?.policies?.length ? context.policies : fetchedPolicies
const availableGroups = context?.policyGroups?.length ? context.policyGroups : fetchedGroups
// Client search debounce
useEffect(() => {
if (context?.clientId || !clientSearch.trim()) { setClientOptions([]); return }
@ -292,13 +312,13 @@ export function AdditionalServiceModal({
</div>
{/* Policy selector */}
{level === 'policy' && context?.policies && context.policies.length > 0 && (
{level === 'policy' && availablePolicies.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) => (
{availablePolicies.map((p) => (
<SelectItem key={p.id} value={p.id}>
{[p.policyNumber, p.policyType].filter(Boolean).join(' · ')}
</SelectItem>
@ -309,13 +329,13 @@ export function AdditionalServiceModal({
)}
{/* Policy Group selector */}
{level === 'group' && context?.policyGroups && context.policyGroups.length > 0 && (
{level === 'group' && availableGroups.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) => (
{availableGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name ?? g.renewalDate ?? g.id}
</SelectItem>

View file

@ -40,6 +40,7 @@ export interface TaskCardTask {
assignments: { id: string; user: { displayName: string | null; email: string } }[]
taskNotes?: { id: string; content: string; createdAt: string; user: { id: string; displayName: string | null; email: string } }[]
isAdHoc?: boolean
anchorGroup?: { id: string; name: string; renewalDate: string | Date } | null
}
function StatusBadge({ status }: { status: string }) {
@ -251,6 +252,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
: 'bg-muted/40'
const levelLabel = task.policyGroup ? 'Group' : task.policy ? 'Policy' : 'Client'
const anchorRenewalDate = (!task.policy && !task.policyGroup && (task as any).anchorGroup?.renewalDate) ? (task as any).anchorGroup.renewalDate : null
return (
<div
@ -286,7 +288,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
<div className="flex flex-wrap gap-x-5 gap-y-0.5 text-[13px]">
<span><span className="text-muted-foreground">Policy #</span> <span className="text-foreground font-medium">{task.policy?.policyNumber || '—'}</span></span>
<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>
<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) : anchorRenewalDate ? <span title="Based on earliest renewal group"> {formatDate(anchorRenewalDate)}</span> : '—'}</span></span>
</div>
{task.policyGroup && (
<div className="relative" ref={groupPopoverRef}>

View file

@ -10,7 +10,9 @@ export function cn(...inputs: ClassValue[]) {
*/
export function formatDate(date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
if (!date) return ''
const dateObj = typeof date === 'string' ? new Date(date) : date
const dateObj = typeof date === 'string'
? new Date(date.includes('T') ? date : date + 'T00:00:00')
: date
return new Intl.DateTimeFormat('en-US', options || {
year: 'numeric',
month: 'short',
@ -24,7 +26,9 @@ export function formatDate(date: Date | string | null | undefined, options?: Int
*/
export function formatRenewalDate(expirationDate: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
if (!expirationDate) return ''
const d = typeof expirationDate === 'string' ? new Date(expirationDate) : new Date(expirationDate)
const d = typeof expirationDate === 'string'
? new Date(expirationDate.includes('T') ? expirationDate : expirationDate + 'T00:00:00')
: new Date(expirationDate)
d.setDate(d.getDate() + 1)
return new Intl.DateTimeFormat('en-US', options || {
year: 'numeric',