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' },
|
||||
})
|
||||
|
||||
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,
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue