From 347e4b342b81fe266ff44c4a39661204eb45f14e Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 30 Jul 2026 10:56:46 +0000 Subject: [PATCH] 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 --- ...-task-generation-preview-and-per-client.md | 18 ++- .../(dashboard)/tasks/assign/page-client.tsx | 125 +++++++++++---- .../src/app/(dashboard)/tasks/assign/page.tsx | 8 +- .../[id]/generate-tasks/route.ts | 21 +++ .../src/components/clients/client-select.tsx | 144 ++++++++++++++++++ .../clients/policy-group-manager.tsx | 115 +++++++++++++- .../generate-and-assign-core.test.ts | 87 ++++++++++- .../src/lib/tasks/generate-and-assign-core.ts | 129 +++++++++++++++- 8 files changed, 583 insertions(+), 64 deletions(-) create mode 100644 ondeck/src/components/clients/client-select.tsx diff --git a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md index 06d3b5a..1e5796f 100644 --- a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md +++ b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md @@ -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. --- diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx index 4091e70..9e51d3b 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState>(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(null) const [previewing, setPreviewing] = useState(false) const [generating, setGenerating] = useState(false) const [preview, setPreview] = useState(null) @@ -290,20 +326,18 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli ) : (
-
+

Client

- - {genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && ( + { + setGenClientId(client?.id || '') + setGenClientAdvocateId(client?.claimsAdvocateId ?? null) + setGenResult(null) + }} + placeholder="Search clients..." + /> + {genClientId && !genClientAdvocateId && (

This client has no claims advocate assigned. Set one before generating tasks.

@@ -311,7 +345,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
@@ -330,6 +364,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli { if (!open) setPreview(null) }}> { if (generating) event.preventDefault() }} @@ -343,24 +378,52 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli and assign them to {preview?.advocateName}. -
+
Client - Tasks + Task + Context + Department + Due Date + Priority - {preview?.clients.map((c) => ( - - {c.name} - {c.estimatedTasks} - - ))} + {preview?.clients.flatMap((c) => + c.tasks.map((t, i) => ( + + {c.name} + {t.title} + {t.context} + {t.department?.replace('_', ' ')} + {formatDate(t.dueDate)} + {t.priority} + + )) + )}
+ {preview && preview.clients.some((c) => breakdownLines(c.breakdown).length > 0) && ( +
+ {preview.clients.map((c) => { + const lines = breakdownLines(c.breakdown) + if (lines.length === 0) return null + return ( +
+ {preview.clients.length > 1 &&

{c.name}

} +
    + {lines.map((line, i) => ( +
  • {line}
  • + ))} +
+
+ ) + })} +
+ )} Cancel @@ -380,13 +443,11 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
- + setClientFilter(client?.id || '_all')} + placeholder="All clients" + /> setQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {displayLabel || placeholder} + + )} +
+ {value && !open && ( + + )} + +
+
+ + {open && ( +
+ {loading && results.length === 0 ? ( +
Searching...
+ ) : results.length === 0 ? ( +
No clients found
+ ) : ( + results.map((c) => ( +
e.preventDefault()} + onClick={() => handleSelect(c)} + > + {c.name} +
+ )) + )} +
+ )} +
+ ) +} diff --git a/ondeck/src/components/clients/policy-group-manager.tsx b/ondeck/src/components/clients/policy-group-manager.tsx index 207059c..634560d 100644 --- a/ondeck/src/components/clients/policy-group-manager.tsx +++ b/ondeck/src/components/clients/policy-group-manager.tsx @@ -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(null) const [cancelTasks, setCancelTasks] = useState(false) const [generatingFor, setGeneratingFor] = useState(null) + const [generatePreview, setGeneratePreview] = useState<{ + groupId: string + groupName: string + tasks: GenerateTaskPreviewItem[] + skipped: number + } | null>(null) + const [generating, setGenerating] = useState(false) const [adhocGroupId, setAdhocGroupId] = useState(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({
) } diff --git a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts index 83c064d..10b7341 100644 --- a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts +++ b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts @@ -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' }]) diff --git a/ondeck/src/lib/tasks/generate-and-assign-core.ts b/ondeck/src/lib/tasks/generate-and-assign-core.ts index 8c58b32..b50f8b4 100644 --- a/ondeck/src/lib/tasks/generate-and-assign-core.ts +++ b/ondeck/src/lib/tasks/generate-and-assign-core.ts @@ -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() + const previewsByClient = new Map() + 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() + 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>() + 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() + 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>() + 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() + 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)