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:
parent
9331618ba6
commit
8b43a25f4d
10 changed files with 563 additions and 17 deletions
|
|
@ -75,5 +75,21 @@ export async function GET(
|
||||||
orderBy: { dueDate: 'asc' },
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
ondeck/src/app/api/policies/[id]/open-task-count/route.ts
Normal file
34
ondeck/src/app/api/policies/[id]/open-task-count/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ondeck/src/app/api/templates/[id]/sync/route.ts
Normal file
130
ondeck/src/app/api/templates/[id]/sync/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Building2,
|
Building2,
|
||||||
Tag,
|
Tag,
|
||||||
|
RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -32,6 +33,16 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -151,6 +162,9 @@ export function TaskTemplateManager({
|
||||||
const [formData, setFormData] = useState(emptyTemplate)
|
const [formData, setFormData] = useState(emptyTemplate)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
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 filteredTemplates = templates.filter((template) => {
|
||||||
const matchesSearch =
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/templates/${id}`, {
|
const response = await fetch(`/api/templates/${id}`, {
|
||||||
|
|
@ -703,9 +745,18 @@ export function TaskTemplateManager({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleOpenEdit(template)}
|
onClick={() => handleOpenEdit(template)}
|
||||||
|
title="Edit template"
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</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 ? (
|
{deleteConfirmId === template.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -741,6 +792,26 @@ export function TaskTemplateManager({
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,11 @@ export function PolicyGroupManager({
|
||||||
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
|
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
|
||||||
const [adhocGroupId, setAdhocGroupId] = useState<string | null>(null)
|
const [adhocGroupId, setAdhocGroupId] = useState<string | null>(null)
|
||||||
const [sessionUserId, setSessionUserId] = useState('')
|
const [sessionUserId, setSessionUserId] = useState('')
|
||||||
|
// Policy movement confirmation
|
||||||
|
const [policyMoveConfirm, setPolicyMoveConfirm] = useState<{
|
||||||
|
addingWithTasks: { policyId: string; policyNumber: string; taskCount: number }[]
|
||||||
|
removing: string[]
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/session')
|
fetch('/api/auth/session')
|
||||||
|
|
@ -145,16 +150,25 @@ export function PolicyGroupManager({
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const doSubmit = async () => {
|
||||||
e.preventDefault()
|
|
||||||
if (!formData.name || !formData.renewalDate) {
|
|
||||||
toast.error('Name and renewal date are required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
setPolicyMoveConfirm(null)
|
||||||
try {
|
try {
|
||||||
if (editingGroup) {
|
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}`, {
|
const res = await fetch(`/api/policy-groups/${editingGroup.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -182,6 +196,14 @@ export function PolicyGroupManager({
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
toast.success('Group updated')
|
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 {
|
} else {
|
||||||
const res = await fetch(`/api/clients/${clientId}/policy-groups`, {
|
const res = await fetch(`/api/clients/${clientId}/policy-groups`, {
|
||||||
method: 'POST',
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -301,8 +361,13 @@ export function PolicyGroupManager({
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<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 isExpanded = expandedGroupId === group.id
|
||||||
|
const isAnchor = group.id === anchorGroupId
|
||||||
return (
|
return (
|
||||||
<Card key={group.id}>
|
<Card key={group.id}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
@ -310,6 +375,9 @@ export function PolicyGroupManager({
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<CardTitle className="text-base">{group.name}</CardTitle>
|
<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">
|
<Badge variant="outline" className="text-xs">
|
||||||
<Calendar className="h-3 w-3 mr-1" />
|
<Calendar className="h-3 w-3 mr-1" />
|
||||||
<span suppressHydrationWarning>{formatDate(group.renewalDate)}</span>
|
<span suppressHydrationWarning>{formatDate(group.renewalDate)}</span>
|
||||||
|
|
@ -419,7 +487,8 @@ export function PolicyGroupManager({
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
})()}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,10 @@ export function AdditionalServiceModal({
|
||||||
const [selectedPolicyId, setSelectedPolicyId] = useState(context?.policyId ?? '')
|
const [selectedPolicyId, setSelectedPolicyId] = useState(context?.policyId ?? '')
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState(context?.policyGroupId ?? '')
|
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
|
// Load users and recent ad hoc tasks when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -131,6 +135,22 @@ export function AdditionalServiceModal({
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [open])
|
}, [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
|
// Client search debounce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (context?.clientId || !clientSearch.trim()) { setClientOptions([]); return }
|
if (context?.clientId || !clientSearch.trim()) { setClientOptions([]); return }
|
||||||
|
|
@ -292,13 +312,13 @@ export function AdditionalServiceModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Policy selector */}
|
{/* Policy selector */}
|
||||||
{level === 'policy' && context?.policies && context.policies.length > 0 && (
|
{level === 'policy' && availablePolicies.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Policy</label>
|
<label className="text-sm font-medium">Policy</label>
|
||||||
<Select value={selectedPolicyId} onValueChange={setSelectedPolicyId}>
|
<Select value={selectedPolicyId} onValueChange={setSelectedPolicyId}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select policy..." /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select policy..." /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{context.policies.map((p) => (
|
{availablePolicies.map((p) => (
|
||||||
<SelectItem key={p.id} value={p.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{[p.policyNumber, p.policyType].filter(Boolean).join(' · ')}
|
{[p.policyNumber, p.policyType].filter(Boolean).join(' · ')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -309,13 +329,13 @@ export function AdditionalServiceModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Policy Group selector */}
|
{/* Policy Group selector */}
|
||||||
{level === 'group' && context?.policyGroups && context.policyGroups.length > 0 && (
|
{level === 'group' && availableGroups.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Policy Group</label>
|
<label className="text-sm font-medium">Policy Group</label>
|
||||||
<Select value={selectedGroupId} onValueChange={setSelectedGroupId}>
|
<Select value={selectedGroupId} onValueChange={setSelectedGroupId}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select group..." /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select group..." /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{context.policyGroups.map((g) => (
|
{availableGroups.map((g) => (
|
||||||
<SelectItem key={g.id} value={g.id}>
|
<SelectItem key={g.id} value={g.id}>
|
||||||
{g.name ?? g.renewalDate ?? g.id}
|
{g.name ?? g.renewalDate ?? g.id}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface TaskCardTask {
|
||||||
assignments: { id: string; user: { displayName: string | null; email: string } }[]
|
assignments: { id: string; user: { displayName: string | null; email: string } }[]
|
||||||
taskNotes?: { id: string; content: string; createdAt: string; user: { id: string; displayName: string | null; email: string } }[]
|
taskNotes?: { id: string; content: string; createdAt: string; user: { id: string; displayName: string | null; email: string } }[]
|
||||||
isAdHoc?: boolean
|
isAdHoc?: boolean
|
||||||
|
anchorGroup?: { id: string; name: string; renewalDate: string | Date } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
|
@ -251,6 +252,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
|
||||||
: 'bg-muted/40'
|
: 'bg-muted/40'
|
||||||
|
|
||||||
const levelLabel = task.policyGroup ? 'Group' : task.policy ? 'Policy' : 'Client'
|
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 (
|
return (
|
||||||
<div
|
<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]">
|
<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">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">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>
|
</div>
|
||||||
{task.policyGroup && (
|
{task.policyGroup && (
|
||||||
<div className="relative" ref={groupPopoverRef}>
|
<div className="relative" ref={groupPopoverRef}>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export function cn(...inputs: ClassValue[]) {
|
||||||
*/
|
*/
|
||||||
export function formatDate(date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
|
export function formatDate(date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
|
||||||
if (!date) return ''
|
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 || {
|
return new Intl.DateTimeFormat('en-US', options || {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
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 {
|
export function formatRenewalDate(expirationDate: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
|
||||||
if (!expirationDate) return ''
|
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)
|
d.setDate(d.getDate() + 1)
|
||||||
return new Intl.DateTimeFormat('en-US', options || {
|
return new Intl.DateTimeFormat('en-US', options || {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue