feat(tasks): add preview-before-generate to policy group task generation

Extend the per-client preview flow to the per-group "Generate Tasks"
button on the client page, backfill missing templates instead of
skipping groups/policies that already have any template task, and add
a reusable searchable ClientSelect component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-07-30 10:56:46 +00:00
parent db7ea9fc86
commit 347e4b342b
8 changed files with 583 additions and 64 deletions

View file

@ -1274,12 +1274,14 @@ git commit -m "feat(tasks): add per-client mode and preview-before-generate to G
**Files:** none (verification only)
- [ ] **Step 1: Run the full test suite**
- [x] **Step 1: Run the full test suite**
Run: `npx jest`
Expected: the new `generate-and-assign-core.test.ts` (13 tests) and `auto-generate.test.ts` (4 tests) pass. The three pre-existing unrelated failures (`auth.test.ts`, `mappers.test.ts`, `renewal-group-recommendations.test.ts`) are expected and out of scope — confirmed pre-existing in an earlier session.
- [ ] **Step 2: Rebuild and restart the app container**
Confirmed 2026-07-18: 113 passed, 13 failed across exactly the 3 expected pre-existing suites (`auth.test.ts`, `mappers.test.ts`, `renewal-group-recommendations.test.ts`); `generate-and-assign-core.test.ts` and `auto-generate.test.ts` both fully green.
- [x] **Step 2: Rebuild and restart the app container**
Run:
```bash
@ -1288,15 +1290,21 @@ docker compose build horizon-app && docker compose up -d horizon-app
```
Expected: build succeeds; `docker compose logs --tail 20 horizon-app` shows `✓ Ready` with no errors.
- [ ] **Step 3: Manually verify the "By Advocate + Designation" preview flow**
Confirmed 2026-07-18: branch `task-gen-preview-and-per-client` fast-forward merged into `main`, image rebuilt, container recreated, logs show `✓ Ready in 321ms` with no errors.
- [x] **Step 3: Manually verify the "By Advocate + Designation" preview flow**
In a browser, sign in as an Admin, go to Tasks → Generate & Assign. Select an advocate + designation known to have at least one client with missing template tasks, click **Preview**. Confirm a dialog opens showing a total, client count, and a per-client breakdown table — and that no new tasks appear in the task list yet (nothing was written). Click **Cancel** and confirm no tasks were created (check via the "Total tasks" count elsewhere or the client's task list). Repeat, this time clicking **Confirm**, and verify tasks now appear and the dialog closes.
- [ ] **Step 4: Manually verify the "By Client" mode**
Confirmed 2026-07-18 via Playwright against localhost:3000, signed in as the local dev Admin account: Dawn Boland + Shape showed "Generate 24 tasks? ... across 6 client(s)" with a 3-row breakdown table (rows only list clients with `estimatedTasks > 0`, by design — see `generate-and-assign-core.ts`). Cancel left the DB task count unchanged (6950). Confirm created exactly 24 tasks (6950→6974) and closed the dialog.
- [x] **Step 4: Manually verify the "By Client" mode**
Switch to the **By Client** tab, pick a client whose `claimsAdvocateId` is set. Click **Preview**, confirm the dialog shows just that one client's row and a total matching it. Confirm. Then pick a client with no claims advocate set and confirm the **Preview** button is disabled with the "no claims advocate assigned" message showing.
- [ ] **Step 5: No commit for this task** — verification only, nothing to stage.
Confirmed 2026-07-18: "BEK TRANS GROUP INC" (advocate set, 0 existing tasks) previewed as "Generate 5 tasks?" with a single matching row; Confirm created exactly 5 tasks for that client. "1000 Howard Boulevard Partners, LP" (no advocate) showed the Preview button disabled with "This client has no claims advocate assigned. Set one before generating tasks."
- [x] **Step 5: No commit for this task** — verification only, nothing to stage.
---

View file

@ -14,6 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { UserSelectContent } from '@/components/ui/user-select-content'
import { ClientSelect } from '@/components/clients/client-select'
import {
Table,
TableBody,
@ -38,12 +39,10 @@ import { formatDate, formatRenewalDate } from '@/lib/utils'
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null }
interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null }
interface SimpleDesignation { id: string; name: string }
interface BulkAssignClientProps {
users: SimpleUser[]
clients: SimpleClient[]
designations: SimpleDesignation[]
}
@ -63,10 +62,46 @@ const DEPT_OPTIONS = [
{ value: 'OTHER', label: 'Other' },
]
interface TaskPreviewItem {
title: string
department: string
dueDate: string
priority: string
context: string
}
interface TaskBreakdown {
groupsWithTasks: number
groupLevelTasks: number
policiesWithTasks: number
policyLevelTasks: number
clientLevelTasks: number
}
interface ClientTaskSummary {
id: string
name: string
estimatedTasks: number
tasks: TaskPreviewItem[]
breakdown: TaskBreakdown
}
function breakdownLines(b: TaskBreakdown): string[] {
const lines: string[] = []
if (b.clientLevelTasks > 0) {
lines.push(`${b.clientLevelTasks} client-level task${b.clientLevelTasks !== 1 ? 's' : ''}`)
}
if (b.groupsWithTasks > 0) {
lines.push(
`${b.groupsWithTasks} renewal group${b.groupsWithTasks !== 1 ? 's' : ''}, ${b.groupLevelTasks} group-level task${b.groupLevelTasks !== 1 ? 's' : ''}`
)
}
if (b.policiesWithTasks > 0) {
lines.push(
`${b.policiesWithTasks} ungrouped polic${b.policiesWithTasks !== 1 ? 'ies' : 'y'}, ${b.policyLevelTasks} policy-level task${b.policyLevelTasks !== 1 ? 's' : ''}`
)
}
return lines
}
interface GenResult {
@ -79,7 +114,7 @@ interface GenResult {
tasksAssigned: number
}
export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) {
export function BulkAssignClient({ users, designations }: BulkAssignClientProps) {
const [tasks, setTasks] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
@ -92,6 +127,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
const [genAdvocate, setGenAdvocate] = useState('')
const [genDesignation, setGenDesignation] = useState('')
const [genClientId, setGenClientId] = useState('')
const [genClientAdvocateId, setGenClientAdvocateId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
const [generating, setGenerating] = useState(false)
const [preview, setPreview] = useState<GenResult | null>(null)
@ -290,20 +326,18 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
</div>
) : (
<div className="flex items-end gap-3 flex-wrap mt-3">
<div className="flex-1 min-w-[240px]">
<div className="w-[320px]">
<p className="text-sm font-medium mb-1.5">Client</p>
<Select value={genClientId || '_none'} onValueChange={(v) => { setGenClientId(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select client..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && (
<ClientSelect
value={genClientId}
onChange={(client) => {
setGenClientId(client?.id || '')
setGenClientAdvocateId(client?.claimsAdvocateId ?? null)
setGenResult(null)
}}
placeholder="Search clients..."
/>
{genClientId && !genClientAdvocateId && (
<p className="text-sm text-destructive mt-1.5">
This client has no claims advocate assigned. Set one before generating tasks.
</p>
@ -311,7 +345,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
</div>
<Button
onClick={handlePreview}
disabled={!genClientId || !clients.find((c) => c.id === genClientId)?.claimsAdvocateId || previewing}
disabled={!genClientId || !genClientAdvocateId || previewing}
>
{previewing ? 'Checking...' : 'Preview'}
</Button>
@ -330,6 +364,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
<AlertDialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null) }}>
<AlertDialogContent
className="sm:!max-w-[80vw]"
onEscapeKeyDown={(event) => {
if (generating) event.preventDefault()
}}
@ -343,24 +378,52 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
and assign them to {preview?.advocateName}.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-64 overflow-y-auto rounded-md border">
<div className="max-h-96 overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead className="text-right">Tasks</TableHead>
<TableHead>Task</TableHead>
<TableHead>Context</TableHead>
<TableHead>Department</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Priority</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview?.clients.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.name}</TableCell>
<TableCell className="text-right">{c.estimatedTasks}</TableCell>
</TableRow>
))}
{preview?.clients.flatMap((c) =>
c.tasks.map((t, i) => (
<TableRow key={`${c.id}-${i}`}>
<TableCell className="text-sm">{c.name}</TableCell>
<TableCell className="text-sm font-medium">{t.title}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.context}</TableCell>
<TableCell className="text-sm">{t.department?.replace('_', ' ')}</TableCell>
<TableCell className="text-sm" suppressHydrationWarning>{formatDate(t.dueDate)}</TableCell>
<TableCell className="text-sm">{t.priority}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{preview && preview.clients.some((c) => breakdownLines(c.breakdown).length > 0) && (
<div className="space-y-2 text-sm">
{preview.clients.map((c) => {
const lines = breakdownLines(c.breakdown)
if (lines.length === 0) return null
return (
<div key={c.id}>
{preview.clients.length > 1 && <p className="font-medium">{c.name}</p>}
<ul className="list-disc list-inside text-muted-foreground">
{lines.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
)
})}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={generating}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmGenerate} disabled={generating}>
@ -380,13 +443,11 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Select value={clientFilter} onValueChange={setClientFilter}>
<SelectTrigger><SelectValue placeholder="All clients" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">All clients</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<ClientSelect
value={clientFilter === '_all' ? '' : clientFilter}
onChange={(client) => setClientFilter(client?.id || '_all')}
placeholder="All clients"
/>
<Select value={designationFilter} onValueChange={setDesignationFilter}>
<SelectTrigger><SelectValue placeholder="All designations" /></SelectTrigger>

View file

@ -15,16 +15,12 @@ export default async function BulkAssignPage() {
redirect('/tasks')
}
const [users, clients, designations] = await Promise.all([
const [users, designations] = await Promise.all([
prisma.user.findMany({
where: { isActive: true },
select: { id: true, displayName: true, email: true, department: true },
orderBy: { displayName: 'asc' },
}),
prisma.client.findMany({
select: { id: true, name: true, claimsAdvocateId: true },
orderBy: { name: 'asc' },
}),
prisma.designation.findMany({
where: { isActive: true },
select: { id: true, name: true },
@ -34,7 +30,7 @@ export default async function BulkAssignPage() {
return (
<div className="container mx-auto py-8">
<BulkAssignClient users={users} clients={clients} designations={designations} />
<BulkAssignClient users={users} designations={designations} />
</div>
)
}

View file

@ -8,6 +8,7 @@ import { isRelevantDueDate } from '@/lib/sync/task-due-date'
* POST /api/policy-groups/[id]/generate-tasks
* Generate tasks from active templates using the group's renewalDate.
* One task per template per group (skips already-generated ones).
* Pass ?dryRun=true to compute the would-be tasks without writing anything.
*/
export async function POST(
request: NextRequest,
@ -25,6 +26,7 @@ export async function POST(
}
const { id } = await params
const dryRun = new URL(request.url).searchParams.get('dryRun') === 'true'
const group = await prisma.policyGroup.findUnique({
where: { id },
@ -71,7 +73,9 @@ export async function POST(
if (templates.length === 0) {
return NextResponse.json({
dryRun,
created: 0,
estimatedTasks: 0,
skipped: alreadyGeneratedTemplateIds.length,
message: 'No new templates to generate tasks from',
})
@ -104,12 +108,28 @@ export async function POST(
if (tasksToCreate.length === 0) {
return NextResponse.json({
dryRun,
created: 0,
estimatedTasks: 0,
skipped: alreadyGeneratedTemplateIds.length,
message: 'No new templates produced a relevant (today-or-later) due date',
})
}
if (dryRun) {
return NextResponse.json({
dryRun: true,
estimatedTasks: tasksToCreate.length,
skipped: alreadyGeneratedTemplateIds.length,
tasks: tasksToCreate.map((t) => ({
title: t.title,
department: t.department,
dueDate: t.dueDate,
priority: t.priority,
})),
})
}
const result = await prisma.task.createMany({
data: tasksToCreate as any[],
})
@ -129,6 +149,7 @@ export async function POST(
})
return NextResponse.json({
dryRun: false,
created: result.count,
skipped: alreadyGeneratedTemplateIds.length,
templates: tasksToCreate.map((t) => ({ name: t.title, dueDate: t.dueDate })),

View file

@ -0,0 +1,144 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { ChevronDown, X } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface ClientOption {
id: string
name: string
claimsAdvocateId: string | null
}
interface ClientSelectProps {
value: string
selectedLabel?: string
onChange: (client: ClientOption | null) => void
placeholder?: string
className?: string
}
const DEBOUNCE_MS = 250
export function ClientSelect({ value, selectedLabel, onChange, placeholder = 'Search clients...', className }: ClientSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<ClientOption[]>([])
const [loading, setLoading] = useState(false)
const [label, setLabel] = useState(selectedLabel)
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestIdRef = useRef(0)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
if (!open) return
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
const requestId = ++requestIdRef.current
setLoading(true)
try {
const params = new URLSearchParams({ limit: '20', sortBy: 'name', sortOrder: 'asc' })
if (query) params.set('search', query)
const res = await fetch(`/api/clients?${params}`)
if (!res.ok) return
const data = await res.json()
if (requestId !== requestIdRef.current) return
setResults(
(data.clients ?? []).map((c: any) => ({
id: c.id,
name: c.name,
claimsAdvocateId: c.claimsAdvocateId ?? null,
}))
)
} finally {
if (requestId === requestIdRef.current) setLoading(false)
}
}, DEBOUNCE_MS)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query, open])
const handleSelect = (client: ClientOption) => {
setLabel(client.name)
onChange(client)
setOpen(false)
setQuery('')
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
setLabel(undefined)
onChange(null)
setQuery('')
}
const displayLabel = value ? label : undefined
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
className="flex items-center border rounded-md bg-background cursor-pointer"
onClick={() => { setOpen((o) => !o); setQuery('') }}
>
{open ? (
<Input
autoFocus
className="border-0 shadow-none focus-visible:ring-0 h-9"
placeholder="Type to search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={cn('flex-1 px-3 py-2 text-sm truncate', !displayLabel && 'text-muted-foreground')}>
{displayLabel || placeholder}
</span>
)}
<div className="flex items-center pr-2 gap-1">
{value && !open && (
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" onClick={handleClear} />
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-60 overflow-y-auto">
{loading && results.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">Searching...</div>
) : results.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">No clients found</div>
) : (
results.map((c) => (
<div
key={c.id}
className={cn(
'px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
c.id === value && 'bg-accent font-medium'
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(c)}
>
{c.name}
</div>
))
)}
</div>
)}
</div>
)
}

View file

@ -40,6 +40,14 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatDate, formatRenewalDate } from '@/lib/utils'
interface Policy {
@ -63,6 +71,13 @@ interface PolicyGroup {
_count: { tasks: number }
}
interface GenerateTaskPreviewItem {
title: string
department: string
dueDate: string
priority: string
}
interface PolicyGroupManagerProps {
clientId: string
initialGroups: PolicyGroup[]
@ -97,6 +112,13 @@ export function PolicyGroupManager({
const [deleteTarget, setDeleteTarget] = useState<PolicyGroup | null>(null)
const [cancelTasks, setCancelTasks] = useState(false)
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
const [generatePreview, setGeneratePreview] = useState<{
groupId: string
groupName: string
tasks: GenerateTaskPreviewItem[]
skipped: number
} | null>(null)
const [generating, setGenerating] = useState(false)
const [adhocGroupId, setAdhocGroupId] = useState<string | null>(null)
const [sessionUserId, setSessionUserId] = useState('')
// Policy movement confirmation
@ -313,10 +335,39 @@ export function PolicyGroupManager({
}
}
const handleGenerateTasks = async (groupId: string) => {
const handlePreviewGenerate = async (groupId: string) => {
setGeneratingFor(groupId)
try {
const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks`, {
const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks?dryRun=true`, {
method: 'POST',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to preview tasks')
if (!data.estimatedTasks) {
toast.info(data.message || 'No new tasks to generate')
return
}
setGeneratePreview({
groupId,
groupName: groups.find((g) => g.id === groupId)?.name ?? 'this group',
tasks: data.tasks ?? [],
skipped: data.skipped ?? 0,
})
} catch (err: any) {
toast.error(err.message || 'Failed to preview tasks')
} finally {
setGeneratingFor(null)
}
}
const handleConfirmGenerate = async (event: React.MouseEvent) => {
event.preventDefault()
if (!generatePreview) return
setGenerating(true)
try {
const res = await fetch(`/api/policy-groups/${generatePreview.groupId}/generate-tasks`, {
method: 'POST',
})
const data = await res.json()
@ -324,7 +375,7 @@ export function PolicyGroupManager({
setGroups((prev) =>
prev.map((g) =>
g.id === groupId
g.id === generatePreview.groupId
? { ...g, _count: { tasks: g._count.tasks + data.created } }
: g
)
@ -336,10 +387,11 @@ export function PolicyGroupManager({
toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`)
onTasksGenerated?.(data.created)
}
setGeneratePreview(null)
} catch (err: any) {
toast.error(err.message || 'Failed to generate tasks')
} finally {
setGeneratingFor(null)
setGenerating(false)
}
}
@ -418,11 +470,11 @@ export function PolicyGroupManager({
<Button
variant="outline"
size="sm"
onClick={() => handleGenerateTasks(group.id)}
onClick={() => handlePreviewGenerate(group.id)}
disabled={generatingFor === group.id}
>
<Zap className="h-3.5 w-3.5 mr-1" />
{generatingFor === group.id ? 'Generating...' : 'Generate Tasks'}
{generatingFor === group.id ? 'Checking...' : 'Generate Tasks'}
</Button>
<Button
variant="ghost"
@ -733,6 +785,57 @@ export function PolicyGroupManager({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Generate Tasks preview */}
<AlertDialog
open={!!generatePreview}
onOpenChange={(open) => { if (!open && !generating) setGeneratePreview(null) }}
>
<AlertDialogContent
className="sm:!max-w-[80vw]"
onEscapeKeyDown={(event) => { if (generating) event.preventDefault() }}
>
<AlertDialogHeader>
<AlertDialogTitle>
Generate {generatePreview?.tasks.length} task{generatePreview && generatePreview.tasks.length !== 1 ? 's' : ''}?
</AlertDialogTitle>
<AlertDialogDescription>
This will create {generatePreview?.tasks.length} task(s) for &quot;{generatePreview?.groupName}&quot;.
{generatePreview && generatePreview.skipped > 0 && (
<> {generatePreview.skipped} template{generatePreview.skipped !== 1 ? 's' : ''} already applied to this group will be skipped.</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-96 overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Department</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Priority</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{generatePreview?.tasks.map((t, i) => (
<TableRow key={i}>
<TableCell className="text-sm font-medium">{t.title}</TableCell>
<TableCell className="text-sm">{t.department?.replace('_', ' ')}</TableCell>
<TableCell className="text-sm" suppressHydrationWarning>{formatDate(t.dueDate)}</TableCell>
<TableCell className="text-sm">{t.priority}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={generating}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmGenerate} disabled={generating}>
{generating ? 'Generating...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -88,7 +88,7 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
{ id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') },
])
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
@ -97,7 +97,10 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
expect(mockAuditLogCreate).not.toHaveBeenCalled()
expect(result.dryRun).toBe(true)
expect(result.totalEstimatedTasks).toBe(1)
expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(result.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(result.clients[0].tasks).toEqual([
expect.objectContaining({ title: 'Prepare loss summary', department: 'Commercial Lines' }),
])
expect(result.advocateName).toBe('Jane Smith')
})
@ -117,7 +120,7 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
{ id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') },
])
mockTaskCreateMany.mockResolvedValue({ count: 1 })
mockTaskFindMany.mockResolvedValue([{ id: 'task-1' }])
@ -177,7 +180,10 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
expect(dryRunResult.totalEstimatedTasks).toBe(1)
expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(dryRunResult.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(dryRunResult.clients[0].tasks).toEqual([
expect.objectContaining({ title: 'Review policy renewal', department: 'Personal Lines' }),
])
expect(mockTaskCreateMany).toHaveBeenCalledTimes(1)
expect(realResult.tasksCreated).toBe(1)
})
@ -209,7 +215,10 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
expect(dryRunResult.totalEstimatedTasks).toBe(1)
expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(dryRunResult.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(dryRunResult.clients[0].tasks).toEqual([
expect.objectContaining({ title: 'Send client renewal letter', context: 'Client-level' }),
])
expect(mockTaskCreate).toHaveBeenCalledTimes(1)
expect(mockTaskCreateMany).not.toHaveBeenCalled()
expect(realResult.tasksCreated).toBe(1)
@ -242,7 +251,7 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
{ id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') },
])
mockPolicyFindMany.mockResolvedValue([
{ id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') },
@ -250,9 +259,71 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 2 }])
expect(result.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 2 }])
expect(result.clients[0].tasks).toHaveLength(2)
expect(result.clients[0].breakdown).toEqual({
groupsWithTasks: 1,
groupLevelTasks: 1,
policiesWithTasks: 1,
policyLevelTasks: 1,
clientLevelTasks: 0,
})
expect(result.totalEstimatedTasks).toBe(2)
})
it('backfills a missing group-level template when the group already has a task from a different template', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
mockClientsForAdvocateDesignation()
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-existing',
name: 'SHAPE Onboarding Checklist',
description: null,
department: 'Claims',
timing: 'PRE_RENEWAL',
daysOffset: -335,
defaultPriority: 'NORMAL',
level: 'BOTH',
},
{
id: 'template-missing',
name: 'Prepare loss summary/analysis for internal pre-renewal meeting',
description: null,
department: 'Claims',
timing: 'PRE_RENEWAL',
daysOffset: -115,
defaultPriority: 'NORMAL',
level: 'BOTH',
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-12-28') },
])
// Group already has a task tied to template-existing; template-missing has never run.
mockTaskFindMany.mockImplementation((args: any) =>
args?.select?.policyGroupId
? Promise.resolve([{ policyGroupId: 'group-1', templateId: 'template-existing' }])
: Promise.resolve([{ id: 'new-task-1' }])
)
mockTaskCreateMany.mockResolvedValue({ count: 1 })
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 1 })
const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
expect(dryRunResult.totalEstimatedTasks).toBe(1)
expect(dryRunResult.clients[0].tasks).toEqual([
expect.objectContaining({ title: 'Prepare loss summary/analysis for internal pre-renewal meeting' }),
])
const groupQueryArgs = mockPolicyGroupFindMany.mock.calls[0][0]
expect(groupQueryArgs.where.tasks).toBeUndefined()
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
expect(mockTaskCreateMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ templateId: 'template-missing' })],
})
expect(realResult.tasksCreated).toBe(1)
})
})
describe('runGenerateAndAssign — client mode', () => {
@ -279,7 +350,7 @@ describe('runGenerateAndAssign — client mode', () => {
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
{ id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') },
])
mockClientFindMany.mockResolvedValue([{ id: 'client-1', name: 'Client One' }])

View file

@ -41,10 +41,28 @@ import { prisma } from '@/lib/db'
import { designationFilter, designationIdsOf } from '@/lib/sync/auto-generate'
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
export interface TaskPreview {
title: string
department: string
dueDate: Date
priority: string
context: string
}
export interface SourceBreakdown {
groupsWithTasks: number
groupLevelTasks: number
policiesWithTasks: number
policyLevelTasks: number
clientLevelTasks: number
}
export interface ClientTaskSummary {
id: string
name: string
estimatedTasks: number
tasks: TaskPreview[]
breakdown: SourceBreakdown
}
export interface GenerateAndAssignResult {
@ -132,6 +150,21 @@ export async function runGenerateAndAssign(
}
const estimatedByClient = new Map<string, number>()
const previewsByClient = new Map<string, TaskPreview[]>()
const addPreview = (clientId: string, preview: TaskPreview) => {
const list = previewsByClient.get(clientId)
if (list) list.push(preview)
else previewsByClient.set(clientId, [preview])
}
const breakdownByClient = new Map<string, SourceBreakdown>()
const getBreakdown = (clientId: string) => {
let breakdown = breakdownByClient.get(clientId)
if (!breakdown) {
breakdown = { groupsWithTasks: 0, groupLevelTasks: 0, policiesWithTasks: 0, policyLevelTasks: 0, clientLevelTasks: 0 }
breakdownByClient.set(clientId, breakdown)
}
return breakdown
}
let tasksCreated = 0
let tasksAssigned = 0
@ -156,13 +189,34 @@ export async function runGenerateAndAssign(
const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY')
const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT')
// Fetched without a "no templated tasks yet" filter — unlike the fully
// idempotent nightly auto-generate job, this manual tool must also backfill
// groups/policies that already have *some* template tasks but are missing
// others (e.g. a new template was added after the group was first
// processed). Per-entity template applicability is resolved below instead.
const groups = await prisma.policyGroup.findMany({
where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } },
where: { clientId: { in: resolved.clientIds } },
})
const groupTemplateTaskRows = await prisma.task.findMany({
where: { policyGroupId: { in: groups.map((g) => g.id) }, templateId: { not: null } },
select: { policyGroupId: true, templateId: true },
})
const existingTemplateIdsByGroup = new Map<string, Set<string>>()
for (const row of groupTemplateTaskRows) {
if (!row.policyGroupId || !row.templateId) continue
const set = existingTemplateIdsByGroup.get(row.policyGroupId)
if (set) set.add(row.templateId)
else existingTemplateIdsByGroup.set(row.policyGroupId, new Set([row.templateId]))
}
for (const group of groups) {
const existingTemplateIds = existingTemplateIdsByGroup.get(group.id) ?? new Set<string>()
const applicableTemplates = groupTemplates.filter((t) => !existingTemplateIds.has(t.id))
if (applicableTemplates.length === 0) continue
const renewalDate = new Date(group.renewalDate)
const tasksToCreate = groupTemplates
const tasksToCreate = applicableTemplates
.map((template) => {
const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
@ -185,6 +239,18 @@ export async function runGenerateAndAssign(
if (tasksToCreate.length === 0) continue
estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length)
for (const task of tasksToCreate) {
addPreview(group.clientId, {
title: task.title,
department: task.department,
dueDate: task.dueDate,
priority: task.priority,
context: group.name,
})
}
const groupBreakdown = getBreakdown(group.clientId)
groupBreakdown.groupsWithTasks += 1
groupBreakdown.groupLevelTasks += tasksToCreate.length
if (options.dryRun) continue
const created = await prisma.task.createMany({ data: tasksToCreate })
@ -192,7 +258,7 @@ export async function runGenerateAndAssign(
if (created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } },
where: { policyGroupId: group.id, templateId: { in: applicableTemplates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
@ -206,13 +272,29 @@ export async function runGenerateAndAssign(
}
const policies = await prisma.policy.findMany({
where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } },
where: { clientId: { in: resolved.clientIds }, policyGroupId: null },
})
const policyTemplateTaskRows = await prisma.task.findMany({
where: { policyId: { in: policies.map((p) => p.id) }, templateId: { not: null } },
select: { policyId: true, templateId: true },
})
const existingTemplateIdsByPolicy = new Map<string, Set<string>>()
for (const row of policyTemplateTaskRows) {
if (!row.policyId || !row.templateId) continue
const set = existingTemplateIdsByPolicy.get(row.policyId)
if (set) set.add(row.templateId)
else existingTemplateIdsByPolicy.set(row.policyId, new Set([row.templateId]))
}
for (const policy of policies) {
const existingTemplateIds = existingTemplateIdsByPolicy.get(policy.id) ?? new Set<string>()
const applicableTemplates = policyTemplates.filter((t) => !existingTemplateIds.has(t.id))
if (applicableTemplates.length === 0) continue
const anchorDate = new Date(policy.expirationDate)
anchorDate.setDate(anchorDate.getDate() + 1)
const tasksToCreate = policyTemplates
const tasksToCreate = applicableTemplates
.map((template) => {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
@ -235,6 +317,19 @@ export async function runGenerateAndAssign(
if (tasksToCreate.length === 0) continue
estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length)
const policyContext = [policy.policyNumber, policy.policyType].filter(Boolean).join(' — ') || 'Policy'
for (const task of tasksToCreate) {
addPreview(policy.clientId, {
title: task.title,
department: task.department,
dueDate: task.dueDate,
priority: task.priority,
context: policyContext,
})
}
const policyBreakdown = getBreakdown(policy.clientId)
policyBreakdown.policiesWithTasks += 1
policyBreakdown.policyLevelTasks += tasksToCreate.length
if (options.dryRun) continue
const created = await prisma.task.createMany({ data: tasksToCreate })
@ -242,7 +337,7 @@ export async function runGenerateAndAssign(
if (created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } },
where: { policyId: policy.id, templateId: { in: applicableTemplates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
@ -288,6 +383,14 @@ export async function runGenerateAndAssign(
if (!isRelevantDueDate(dueDate)) continue
estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1)
addPreview(clientId, {
title: template.name,
department: template.department,
dueDate,
priority: template.defaultPriority,
context: 'Client-level',
})
getBreakdown(clientId).clientLevelTasks += 1
if (options.dryRun) continue
const createdTask = await prisma.task.create({
@ -341,7 +444,19 @@ export async function runGenerateAndAssign(
const nameById = new Map(clientNames.map((c) => [c.id, c.name]))
const clients: ClientTaskSummary[] = resolved.clientIds
.map((id) => ({ id, name: nameById.get(id) ?? id, estimatedTasks: estimatedByClient.get(id) ?? 0 }))
.map((id) => ({
id,
name: nameById.get(id) ?? id,
estimatedTasks: estimatedByClient.get(id) ?? 0,
tasks: (previewsByClient.get(id) ?? []).sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime()),
breakdown: breakdownByClient.get(id) ?? {
groupsWithTasks: 0,
groupLevelTasks: 0,
policiesWithTasks: 0,
policyLevelTasks: 0,
clientLevelTasks: 0,
},
}))
.filter((c) => c.estimatedTasks > 0)
.sort((a, b) => b.estimatedTasks - a.estimatedTasks)