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>
1315 lines
52 KiB
Markdown
1315 lines
52 KiB
Markdown
# Task Generation: Per-Client Mode & Preview-Before-Generate Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a per-client targeting mode and a preview-and-confirm step to the admin "Generate & Assign Tasks" tool, so admins can generate tasks for one specific client and always see what will happen before anything is created.
|
||
|
||
**Architecture:** Extract the existing route's client/group/policy/template-matching logic into a testable `src/lib/tasks/generate-and-assign-core.ts` module that computes the same `tasksToCreate` arrays whether or not it actually writes them (`dryRun` flag branches only at the write step). The route becomes a thin HTTP wrapper. The frontend calls the endpoint once with `dryRun: true` to populate a confirmation dialog, then again for real on confirm.
|
||
|
||
**Tech Stack:** Next.js 15 App Router route handlers, Prisma, React client component, shadcn/ui (`Tabs`, `AlertDialog`, `Table`), Jest.
|
||
|
||
## Global Constraints
|
||
- Preview/dry-run applies only to `/api/tasks/generate-and-assign` (this admin page). No other generation entry point in the app changes.
|
||
- Client mode always assigns to the target client's own `claimsAdvocateId` — never an arbitrary pick.
|
||
- Preview shows summary counts (total + per-client breakdown), not a full task-by-task list.
|
||
- Every task-creation call site must keep using `isRelevantDueDate` (from `src/lib/sync/task-due-date.ts`) to skip past-due tasks — this is existing behavior from the prior fix and must not regress.
|
||
|
||
---
|
||
|
||
### Task 1: Export shared designation-matching helpers from `auto-generate.ts`
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/sync/auto-generate.ts:8,19`
|
||
|
||
**Interfaces:**
|
||
- Produces: `export function designationIdsOf(entity: { designationId: string | null; designation2Id: string | null }): string[]` and `export function designationFilter(ids: string[]): Array<{ designationId: string | null } | { designationId: { in: string[] } }>` — both now importable from `@/lib/sync/auto-generate`.
|
||
|
||
- [ ] **Step 1: Add `export` to both helper function declarations**
|
||
|
||
In `src/lib/sync/auto-generate.ts`, change:
|
||
```typescript
|
||
function designationIdsOf(entity: {
|
||
```
|
||
to:
|
||
```typescript
|
||
export function designationIdsOf(entity: {
|
||
```
|
||
|
||
And change:
|
||
```typescript
|
||
function designationFilter(ids: string[]) {
|
||
```
|
||
to:
|
||
```typescript
|
||
export function designationFilter(ids: string[]) {
|
||
```
|
||
|
||
- [ ] **Step 2: Run the existing test suite for this file to confirm no regression**
|
||
|
||
Run: `npx jest src/lib/sync/__tests__/auto-generate.test.ts`
|
||
Expected: `4 passed, 4 total` (unchanged from before this edit — adding `export` doesn't change behavior).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/lib/sync/auto-generate.ts
|
||
git commit -m "refactor(sync): export designation-matching helpers for reuse"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Core module — target parsing/validation (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/tasks/generate-and-assign-core.ts`
|
||
- Test: `src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing yet (pure parsing logic, no DB calls in this task).
|
||
- Produces:
|
||
- `export class GenerateAndAssignError extends Error { status: number }`
|
||
- `export type GenerateTarget = { mode: 'advocate-designation'; advocateId: string; designationId: string } | { mode: 'client'; clientId: string }`
|
||
- `export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarget; dryRun: boolean }`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/lib/tasks/__tests__/generate-and-assign-core.test.ts`:
|
||
```typescript
|
||
import { parseGenerateAndAssignTarget, GenerateAndAssignError } from '../generate-and-assign-core'
|
||
|
||
describe('parseGenerateAndAssignTarget', () => {
|
||
it('parses advocate+designation mode', () => {
|
||
const { target, dryRun } = parseGenerateAndAssignTarget({
|
||
advocateId: 'adv-1',
|
||
designationId: 'des-1',
|
||
})
|
||
expect(target).toEqual({ mode: 'advocate-designation', advocateId: 'adv-1', designationId: 'des-1' })
|
||
expect(dryRun).toBe(false)
|
||
})
|
||
|
||
it('parses client mode', () => {
|
||
const { target, dryRun } = parseGenerateAndAssignTarget({ clientId: 'client-1', dryRun: true })
|
||
expect(target).toEqual({ mode: 'client', clientId: 'client-1' })
|
||
expect(dryRun).toBe(true)
|
||
})
|
||
|
||
it('defaults dryRun to false when omitted', () => {
|
||
const { dryRun } = parseGenerateAndAssignTarget({ clientId: 'client-1' })
|
||
expect(dryRun).toBe(false)
|
||
})
|
||
|
||
it('throws when both modes are provided', () => {
|
||
expect(() =>
|
||
parseGenerateAndAssignTarget({ advocateId: 'a', designationId: 'd', clientId: 'c' })
|
||
).toThrow(GenerateAndAssignError)
|
||
})
|
||
|
||
it('throws when neither mode is provided', () => {
|
||
expect(() => parseGenerateAndAssignTarget({})).toThrow(GenerateAndAssignError)
|
||
})
|
||
|
||
it('throws when advocateId is given without designationId', () => {
|
||
expect(() => parseGenerateAndAssignTarget({ advocateId: 'a' })).toThrow(GenerateAndAssignError)
|
||
})
|
||
|
||
it('sets a 400 status on validation errors', () => {
|
||
try {
|
||
parseGenerateAndAssignTarget({})
|
||
fail('expected throw')
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(GenerateAndAssignError)
|
||
expect((err as GenerateAndAssignError).status).toBe(400)
|
||
}
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
Expected: FAIL — `Cannot find module '../generate-and-assign-core'`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/lib/tasks/generate-and-assign-core.ts`:
|
||
```typescript
|
||
export class GenerateAndAssignError extends Error {
|
||
status: number
|
||
constructor(message: string, status: number) {
|
||
super(message)
|
||
this.status = status
|
||
}
|
||
}
|
||
|
||
export type GenerateTarget =
|
||
| { mode: 'advocate-designation'; advocateId: string; designationId: string }
|
||
| { mode: 'client'; clientId: string }
|
||
|
||
/**
|
||
* Parses and validates a generate-and-assign request body. Exactly one of
|
||
* (advocateId + designationId) or clientId must be present.
|
||
*/
|
||
export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarget; dryRun: boolean } {
|
||
const dryRun = body?.dryRun === true
|
||
const hasAdvocateDesignation = !!body?.advocateId && !!body?.designationId
|
||
const hasClient = !!body?.clientId
|
||
|
||
if (hasAdvocateDesignation && hasClient) {
|
||
throw new GenerateAndAssignError(
|
||
'Provide either advocateId+designationId or clientId, not both',
|
||
400
|
||
)
|
||
}
|
||
if (hasClient) {
|
||
return { target: { mode: 'client', clientId: body.clientId }, dryRun }
|
||
}
|
||
if (hasAdvocateDesignation) {
|
||
return {
|
||
target: { mode: 'advocate-designation', advocateId: body.advocateId, designationId: body.designationId },
|
||
dryRun,
|
||
}
|
||
}
|
||
throw new GenerateAndAssignError('advocateId and designationId, or clientId, are required', 400)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
Expected: `7 passed, 7 total`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/tasks/generate-and-assign-core.ts src/lib/tasks/__tests__/generate-and-assign-core.test.ts
|
||
git commit -m "feat(tasks): add request parsing for generate-and-assign core module"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Core module — target resolution + `runGenerateAndAssign` for advocate-designation mode (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/tasks/generate-and-assign-core.ts`
|
||
- Modify: `src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `designationFilter`, `designationIdsOf` from `@/lib/sync/auto-generate` (Task 1); `isRelevantDueDate` from `@/lib/sync/task-due-date`; `prisma` from `@/lib/db`.
|
||
- Produces:
|
||
- `export interface ClientTaskSummary { id: string; name: string; estimatedTasks: number }`
|
||
- `export interface GenerateAndAssignResult { dryRun: boolean; clientsFound: number; totalEstimatedTasks: number; advocateName: string | null; clients: ClientTaskSummary[]; tasksCreated: number; tasksAssigned: number }`
|
||
- `export async function runGenerateAndAssign(target: GenerateTarget, options: { dryRun: boolean; actorUserId: string }): Promise<GenerateAndAssignResult>`
|
||
|
||
This task covers the `advocate-designation` mode only (client mode is Task 4). The mock setup below only exercises that path; client-mode tests in Task 4 extend the same mock file.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to the top of `src/lib/tasks/__tests__/generate-and-assign-core.test.ts` (above the existing `describe` block), replacing the plain import with a mocked one:
|
||
|
||
```typescript
|
||
const mockUserFindUnique = jest.fn()
|
||
const mockClientFindMany = jest.fn()
|
||
const mockClientFindUnique = jest.fn()
|
||
const mockTaskTemplateFindMany = jest.fn()
|
||
const mockPolicyGroupFindMany = jest.fn()
|
||
const mockPolicyFindMany = jest.fn()
|
||
const mockTaskCreateMany = jest.fn().mockResolvedValue({ count: 0 })
|
||
const mockTaskCreate = jest.fn()
|
||
const mockTaskFindMany = jest.fn().mockResolvedValue([])
|
||
const mockTaskFindFirst = jest.fn().mockResolvedValue(null)
|
||
const mockTaskAssignmentCreateMany = jest.fn().mockResolvedValue({ count: 0 })
|
||
const mockTaskAssignmentCreate = jest.fn()
|
||
const mockAuditLogCreate = jest.fn().mockResolvedValue({})
|
||
|
||
jest.mock('@/lib/db', () => ({
|
||
prisma: {
|
||
user: { findUnique: (...args: any[]) => mockUserFindUnique(...args) },
|
||
client: {
|
||
findMany: (...args: any[]) => mockClientFindMany(...args),
|
||
findUnique: (...args: any[]) => mockClientFindUnique(...args),
|
||
},
|
||
taskTemplate: { findMany: (...args: any[]) => mockTaskTemplateFindMany(...args) },
|
||
policyGroup: { findMany: (...args: any[]) => mockPolicyGroupFindMany(...args) },
|
||
policy: { findMany: (...args: any[]) => mockPolicyFindMany(...args) },
|
||
task: {
|
||
createMany: (...args: any[]) => mockTaskCreateMany(...args),
|
||
create: (...args: any[]) => mockTaskCreate(...args),
|
||
findMany: (...args: any[]) => mockTaskFindMany(...args),
|
||
findFirst: (...args: any[]) => mockTaskFindFirst(...args),
|
||
},
|
||
taskAssignment: {
|
||
createMany: (...args: any[]) => mockTaskAssignmentCreateMany(...args),
|
||
create: (...args: any[]) => mockTaskAssignmentCreate(...args),
|
||
},
|
||
auditLog: { create: (...args: any[]) => mockAuditLogCreate(...args) },
|
||
},
|
||
}))
|
||
|
||
import { runGenerateAndAssign } from '../generate-and-assign-core'
|
||
|
||
const now = new Date('2026-07-18T00:00:00Z')
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
jest.useFakeTimers().setSystemTime(now)
|
||
mockClientFindMany.mockResolvedValue([])
|
||
mockTaskTemplateFindMany.mockResolvedValue([])
|
||
mockPolicyGroupFindMany.mockResolvedValue([])
|
||
mockPolicyFindMany.mockResolvedValue([])
|
||
mockTaskCreateMany.mockResolvedValue({ count: 0 })
|
||
mockTaskFindMany.mockResolvedValue([])
|
||
mockTaskFindFirst.mockResolvedValue(null)
|
||
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 0 })
|
||
})
|
||
|
||
afterEach(() => {
|
||
jest.useRealTimers()
|
||
})
|
||
|
||
describe('runGenerateAndAssign — advocate-designation mode', () => {
|
||
const target = { mode: 'advocate-designation' as const, advocateId: 'adv-1', designationId: 'des-1' }
|
||
|
||
// The implementation calls client.findMany twice: once to find clients
|
||
// matching the advocate+designation (select: { id }), and once at the end
|
||
// to look up display names for the summary (select: { id, name }). This
|
||
// mock serves both from one place, branching on which fields were selected.
|
||
const mockClientsForAdvocateDesignation = () => {
|
||
mockClientFindMany.mockImplementation((args: any) =>
|
||
args?.select?.name
|
||
? Promise.resolve([{ id: 'client-1', name: 'Client One' }])
|
||
: Promise.resolve([{ id: 'client-1' }])
|
||
)
|
||
}
|
||
|
||
it('computes counts without writing when dryRun is true', async () => {
|
||
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||
mockClientsForAdvocateDesignation()
|
||
mockTaskTemplateFindMany.mockResolvedValue([
|
||
{
|
||
id: 'template-1',
|
||
name: 'Prepare loss summary',
|
||
description: null,
|
||
department: 'Commercial Lines',
|
||
timing: 'PRE_RENEWAL',
|
||
daysOffset: -1,
|
||
defaultPriority: 'NORMAL',
|
||
level: 'RENEWAL_GROUP',
|
||
},
|
||
])
|
||
mockPolicyGroupFindMany.mockResolvedValue([
|
||
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
|
||
])
|
||
|
||
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||
|
||
expect(mockTaskCreateMany).not.toHaveBeenCalled()
|
||
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.advocateName).toBe('Jane Smith')
|
||
})
|
||
|
||
it('creates and assigns tasks, matching the dry-run count, when dryRun is false', async () => {
|
||
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||
mockClientsForAdvocateDesignation()
|
||
mockTaskTemplateFindMany.mockResolvedValue([
|
||
{
|
||
id: 'template-1',
|
||
name: 'Prepare loss summary',
|
||
description: null,
|
||
department: 'Commercial Lines',
|
||
timing: 'PRE_RENEWAL',
|
||
daysOffset: -1,
|
||
defaultPriority: 'NORMAL',
|
||
level: 'RENEWAL_GROUP',
|
||
},
|
||
])
|
||
mockPolicyGroupFindMany.mockResolvedValue([
|
||
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
|
||
])
|
||
mockTaskCreateMany.mockResolvedValue({ count: 1 })
|
||
mockTaskFindMany.mockResolvedValue([{ id: 'task-1' }])
|
||
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 1 })
|
||
|
||
const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
|
||
|
||
expect(mockTaskCreateMany).toHaveBeenCalledTimes(1)
|
||
expect(mockAuditLogCreate).toHaveBeenCalledTimes(1)
|
||
expect(realResult.tasksCreated).toBe(dryRunResult.totalEstimatedTasks)
|
||
expect(realResult.tasksAssigned).toBe(1)
|
||
})
|
||
|
||
it('throws 404 when the advocate is not found or inactive', async () => {
|
||
mockUserFindUnique.mockResolvedValue(null)
|
||
await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
|
||
status: 404,
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
Expected: FAIL — `runGenerateAndAssign is not a function` (or similar export-not-found error)
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Add to `src/lib/tasks/generate-and-assign-core.ts` (below the existing `parseGenerateAndAssignTarget`):
|
||
|
||
```typescript
|
||
import { prisma } from '@/lib/db'
|
||
import { designationFilter, designationIdsOf } from '@/lib/sync/auto-generate'
|
||
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
|
||
|
||
export interface ClientTaskSummary {
|
||
id: string
|
||
name: string
|
||
estimatedTasks: number
|
||
}
|
||
|
||
export interface GenerateAndAssignResult {
|
||
dryRun: boolean
|
||
clientsFound: number
|
||
totalEstimatedTasks: number
|
||
advocateName: string | null
|
||
clients: ClientTaskSummary[]
|
||
tasksCreated: number
|
||
tasksAssigned: number
|
||
}
|
||
|
||
interface ResolvedTarget {
|
||
advocateId: string
|
||
advocateName: string | null
|
||
clientIds: string[]
|
||
templateDesignationIds: string[]
|
||
}
|
||
|
||
async function resolveTarget(target: GenerateTarget): Promise<ResolvedTarget> {
|
||
if (target.mode === 'advocate-designation') {
|
||
const advocate = await prisma.user.findUnique({
|
||
where: { id: target.advocateId },
|
||
select: { id: true, displayName: true, isActive: true },
|
||
})
|
||
if (!advocate || !advocate.isActive) {
|
||
throw new GenerateAndAssignError('Advocate not found or inactive', 404)
|
||
}
|
||
const clients = await prisma.client.findMany({
|
||
where: {
|
||
claimsAdvocateId: target.advocateId,
|
||
OR: [{ designationId: target.designationId }, { designation2Id: target.designationId }],
|
||
},
|
||
select: { id: true },
|
||
})
|
||
return {
|
||
advocateId: advocate.id,
|
||
advocateName: advocate.displayName,
|
||
clientIds: clients.map((c) => c.id),
|
||
templateDesignationIds: [target.designationId],
|
||
}
|
||
}
|
||
|
||
const client = await prisma.client.findUnique({
|
||
where: { id: target.clientId },
|
||
select: { id: true, claimsAdvocateId: true, designationId: true, designation2Id: true },
|
||
})
|
||
if (!client) {
|
||
throw new GenerateAndAssignError('Client not found', 404)
|
||
}
|
||
if (!client.claimsAdvocateId) {
|
||
throw new GenerateAndAssignError('Client has no claims advocate assigned', 400)
|
||
}
|
||
const advocate = await prisma.user.findUnique({
|
||
where: { id: client.claimsAdvocateId },
|
||
select: { id: true, displayName: true, isActive: true },
|
||
})
|
||
if (!advocate || !advocate.isActive) {
|
||
throw new GenerateAndAssignError('Advocate not found or inactive', 404)
|
||
}
|
||
return {
|
||
advocateId: advocate.id,
|
||
advocateName: advocate.displayName,
|
||
clientIds: [client.id],
|
||
templateDesignationIds: designationIdsOf(client),
|
||
}
|
||
}
|
||
|
||
export async function runGenerateAndAssign(
|
||
target: GenerateTarget,
|
||
options: { dryRun: boolean; actorUserId: string }
|
||
): Promise<GenerateAndAssignResult> {
|
||
const resolved = await resolveTarget(target)
|
||
|
||
if (resolved.clientIds.length === 0) {
|
||
return {
|
||
dryRun: options.dryRun,
|
||
clientsFound: 0,
|
||
totalEstimatedTasks: 0,
|
||
advocateName: resolved.advocateName,
|
||
clients: [],
|
||
tasksCreated: 0,
|
||
tasksAssigned: 0,
|
||
}
|
||
}
|
||
|
||
const estimatedByClient = new Map<string, number>()
|
||
let tasksCreated = 0
|
||
let tasksAssigned = 0
|
||
|
||
const allTemplates = await prisma.taskTemplate.findMany({
|
||
where: { isActive: true, OR: designationFilter(resolved.templateDesignationIds) },
|
||
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
||
})
|
||
|
||
if (allTemplates.length > 0) {
|
||
const groupTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP')
|
||
const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY')
|
||
const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT')
|
||
|
||
const groups = await prisma.policyGroup.findMany({
|
||
where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } },
|
||
})
|
||
|
||
for (const group of groups) {
|
||
const renewalDate = new Date(group.renewalDate)
|
||
const tasksToCreate = groupTemplates
|
||
.map((template) => {
|
||
const dueDate = new Date(renewalDate)
|
||
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: group.clientId,
|
||
policyGroupId: group.id,
|
||
templateId: template.id,
|
||
createdBy: options.actorUserId,
|
||
}
|
||
})
|
||
.filter((task) => isRelevantDueDate(task.dueDate))
|
||
|
||
if (tasksToCreate.length === 0) continue
|
||
estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length)
|
||
if (options.dryRun) continue
|
||
|
||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||
tasksCreated += created.count
|
||
|
||
if (created.count > 0) {
|
||
const newTasks = await prisma.task.findMany({
|
||
where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } },
|
||
select: { id: true },
|
||
})
|
||
if (newTasks.length > 0) {
|
||
const assigned = await prisma.taskAssignment.createMany({
|
||
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
|
||
skipDuplicates: true,
|
||
})
|
||
tasksAssigned += assigned.count
|
||
}
|
||
}
|
||
}
|
||
|
||
const policies = await prisma.policy.findMany({
|
||
where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } },
|
||
})
|
||
|
||
for (const policy of policies) {
|
||
const anchorDate = new Date(policy.expirationDate)
|
||
anchorDate.setDate(anchorDate.getDate() + 1)
|
||
const tasksToCreate = policyTemplates
|
||
.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: options.actorUserId,
|
||
}
|
||
})
|
||
.filter((task) => isRelevantDueDate(task.dueDate))
|
||
|
||
if (tasksToCreate.length === 0) continue
|
||
estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length)
|
||
if (options.dryRun) continue
|
||
|
||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||
tasksCreated += created.count
|
||
|
||
if (created.count > 0) {
|
||
const newTasks = await prisma.task.findMany({
|
||
where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } },
|
||
select: { id: true },
|
||
})
|
||
if (newTasks.length > 0) {
|
||
const assigned = await prisma.taskAssignment.createMany({
|
||
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
|
||
skipDuplicates: true,
|
||
})
|
||
tasksAssigned += assigned.count
|
||
}
|
||
}
|
||
}
|
||
|
||
if (clientTemplates.length > 0) {
|
||
for (const clientId of resolved.clientIds) {
|
||
const clientRecord = await prisma.client.findUnique({
|
||
where: { id: clientId },
|
||
select: {
|
||
policyGroups: { select: { renewalDate: true } },
|
||
policies: { where: { policyGroupId: null }, select: { expirationDate: true } },
|
||
},
|
||
})
|
||
if (!clientRecord) continue
|
||
|
||
const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
|
||
const policyDates = clientRecord.policies.map((p) => {
|
||
const d = new Date(p.expirationDate)
|
||
d.setDate(d.getDate() + 1)
|
||
return d.getTime()
|
||
})
|
||
const allDates = [...groupDates, ...policyDates]
|
||
if (allDates.length === 0) continue
|
||
const anchorDate = new Date(Math.min(...allDates))
|
||
|
||
for (const template of clientTemplates) {
|
||
const existing = await prisma.task.findFirst({
|
||
where: { clientId, templateId: template.id, policyId: null, policyGroupId: null },
|
||
select: { id: true },
|
||
})
|
||
if (existing) continue
|
||
|
||
const dueDate = new Date(anchorDate)
|
||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||
if (!isRelevantDueDate(dueDate)) continue
|
||
|
||
estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1)
|
||
if (options.dryRun) continue
|
||
|
||
const createdTask = await prisma.task.create({
|
||
data: {
|
||
title: template.name,
|
||
description: template.description,
|
||
department: template.department,
|
||
timing: template.timing,
|
||
daysOffset: template.daysOffset,
|
||
dueDate,
|
||
status: 'NOT_STARTED',
|
||
priority: template.defaultPriority,
|
||
clientId,
|
||
templateId: template.id,
|
||
createdBy: options.actorUserId,
|
||
},
|
||
select: { id: true },
|
||
})
|
||
tasksCreated++
|
||
|
||
await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } })
|
||
tasksAssigned++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!options.dryRun) {
|
||
await prisma.auditLog.create({
|
||
data: {
|
||
userId: options.actorUserId,
|
||
action: 'GENERATE_AND_ASSIGN_TASKS',
|
||
entityType: 'Task',
|
||
newValues: {
|
||
mode: target.mode,
|
||
...(target.mode === 'advocate-designation'
|
||
? { advocateId: target.advocateId, designationId: target.designationId }
|
||
: { clientId: target.clientId }),
|
||
advocateName: resolved.advocateName,
|
||
clientsFound: resolved.clientIds.length,
|
||
tasksCreated,
|
||
tasksAssigned,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
const clientNames = await prisma.client.findMany({
|
||
where: { id: { in: resolved.clientIds } },
|
||
select: { id: true, name: true },
|
||
})
|
||
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 }))
|
||
.filter((c) => c.estimatedTasks > 0)
|
||
.sort((a, b) => b.estimatedTasks - a.estimatedTasks)
|
||
|
||
const totalEstimatedTasks = clients.reduce((sum, c) => sum + c.estimatedTasks, 0)
|
||
|
||
return {
|
||
dryRun: options.dryRun,
|
||
clientsFound: resolved.clientIds.length,
|
||
totalEstimatedTasks,
|
||
advocateName: resolved.advocateName,
|
||
clients,
|
||
tasksCreated,
|
||
tasksAssigned,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
Expected: `10 passed, 10 total`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/tasks/generate-and-assign-core.ts src/lib/tasks/__tests__/generate-and-assign-core.test.ts
|
||
git commit -m "feat(tasks): implement runGenerateAndAssign for advocate-designation mode"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Core module — client mode + missing-advocate guard (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/tasks/__tests__/generate-and-assign-core.test.ts` (implementation already handles client mode from Task 3 — this task only adds coverage)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `runGenerateAndAssign`, `GenerateAndAssignError` from Task 3.
|
||
- Produces: nothing new — verifies existing behavior.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `src/lib/tasks/__tests__/generate-and-assign-core.test.ts`:
|
||
```typescript
|
||
describe('runGenerateAndAssign — client mode', () => {
|
||
const target = { mode: 'client' as const, clientId: 'client-1' }
|
||
|
||
it('targets only the specified client and assigns to its own claims advocate', async () => {
|
||
mockClientFindUnique.mockResolvedValueOnce({
|
||
id: 'client-1',
|
||
claimsAdvocateId: 'adv-2',
|
||
designationId: 'des-1',
|
||
designation2Id: null,
|
||
})
|
||
mockUserFindUnique.mockResolvedValue({ id: 'adv-2', displayName: 'Bob Advocate', isActive: true })
|
||
mockTaskTemplateFindMany.mockResolvedValue([
|
||
{
|
||
id: 'template-1',
|
||
name: 'Prepare loss summary',
|
||
description: null,
|
||
department: 'Commercial Lines',
|
||
timing: 'PRE_RENEWAL',
|
||
daysOffset: -1,
|
||
defaultPriority: 'NORMAL',
|
||
level: 'RENEWAL_GROUP',
|
||
},
|
||
])
|
||
mockPolicyGroupFindMany.mockResolvedValue([
|
||
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
|
||
])
|
||
mockClientFindMany.mockResolvedValue([{ id: 'client-1', name: 'Client One' }])
|
||
|
||
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||
|
||
expect(mockPolicyGroupFindMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: expect.objectContaining({ clientId: { in: ['client-1'] } }) })
|
||
)
|
||
expect(result.advocateName).toBe('Bob Advocate')
|
||
expect(result.clientsFound).toBe(1)
|
||
})
|
||
|
||
it('throws a 400 error when the client has no claims advocate assigned', async () => {
|
||
mockClientFindUnique.mockResolvedValueOnce({
|
||
id: 'client-1',
|
||
claimsAdvocateId: null,
|
||
designationId: null,
|
||
designation2Id: null,
|
||
})
|
||
|
||
await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
|
||
status: 400,
|
||
message: 'Client has no claims advocate assigned',
|
||
})
|
||
})
|
||
|
||
it('throws a 404 error when the client does not exist', async () => {
|
||
mockClientFindUnique.mockResolvedValueOnce(null)
|
||
|
||
await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
|
||
status: 404,
|
||
message: 'Client not found',
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
Expected: FAIL on the first new test — `mockClientFindUnique` was declared in Task 3's mock setup but `resolveTarget`'s client-mode branch must be reachable; if it fails on an assertion mismatch (not a missing-function error), check that `mockClientFindMany` in this test file's `beforeEach` doesn't overwrite the per-test `mockResolvedValueOnce` on `mockClientFindUnique` — these are separate mock functions so no clash is expected. It should fail here simply because `mockClientFindUnique` was never called yet in earlier tests, i.e. this exercises new code paths for the first time — expect real assertion failures if the implementation from Task 3 has a bug, otherwise these should already pass since Task 3 implemented client mode.
|
||
|
||
- [ ] **Step 3: No implementation changes expected**
|
||
|
||
Client mode was already implemented in Task 3's `resolveTarget`. If Step 2 failed, re-read `resolveTarget`'s `client` branch in `src/lib/tasks/generate-and-assign-core.ts` and fix the specific mismatch reported — do not add new logic beyond what's already there.
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts`
|
||
Expected: `13 passed, 13 total`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/tasks/__tests__/generate-and-assign-core.test.ts
|
||
git commit -m "test(tasks): cover client-mode targeting and missing-advocate guard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Refactor the route to a thin wrapper around the core module
|
||
|
||
**Files:**
|
||
- Modify: `src/app/api/tasks/generate-and-assign/route.ts` (full rewrite of the file body)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `parseGenerateAndAssignTarget`, `runGenerateAndAssign`, `GenerateAndAssignError` from `@/lib/tasks/generate-and-assign-core`.
|
||
- Produces: `POST` handler returning `GenerateAndAssignResult` JSON (shape from Task 3) instead of the old `{ tasksCreated, tasksAssigned, clientsFound, groupsProcessed, advocateName, message? }` shape. This is a breaking response-shape change — Task 7 updates the only consumer.
|
||
|
||
- [ ] **Step 1: Replace the file contents**
|
||
|
||
Replace the entire contents of `src/app/api/tasks/generate-and-assign/route.ts` with:
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/lib/auth'
|
||
import {
|
||
parseGenerateAndAssignTarget,
|
||
runGenerateAndAssign,
|
||
GenerateAndAssignError,
|
||
} from '@/lib/tasks/generate-and-assign-core'
|
||
|
||
/**
|
||
* POST /api/tasks/generate-and-assign
|
||
* Generate tasks from active templates for either (a) every client matching
|
||
* an advocate + designation, or (b) one specific client, and assign them to
|
||
* the relevant claims advocate. Pass `dryRun: true` to compute the summary
|
||
* without creating anything.
|
||
* Body: { advocateId, designationId, dryRun? } | { clientId, dryRun? }
|
||
* Requires Admin or Manager role.
|
||
*/
|
||
export async function POST(request: NextRequest) {
|
||
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 body = await request.json()
|
||
const { target, dryRun } = parseGenerateAndAssignTarget(body)
|
||
|
||
const result = await runGenerateAndAssign(target, {
|
||
dryRun,
|
||
actorUserId: (session.user as any).id,
|
||
})
|
||
|
||
return NextResponse.json(result)
|
||
} catch (error: any) {
|
||
if (error instanceof GenerateAndAssignError) {
|
||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||
}
|
||
console.error('Generate and assign error:', error)
|
||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the full core-module test suite plus a typecheck**
|
||
|
||
Run: `npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts && npx tsc --noEmit`
|
||
Expected: `13 passed, 13 total`, and no new TypeScript errors introduced by this file (pre-existing unrelated errors elsewhere in the repo, if any, are out of scope).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/tasks/generate-and-assign/route.ts
|
||
git commit -m "refactor(tasks): route generate-and-assign through the shared core module"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Server component — expose `claimsAdvocateId` on each client
|
||
|
||
**Files:**
|
||
- Modify: `src/app/(dashboard)/tasks/assign/page.tsx:24-27`
|
||
|
||
**Interfaces:**
|
||
- Produces: `clients` prop passed to `BulkAssignClient` now includes `claimsAdvocateId: string | null` per client (consumed by Task 7's updated `SimpleClient` interface).
|
||
|
||
- [ ] **Step 1: Add `claimsAdvocateId` to the client select**
|
||
|
||
In `src/app/(dashboard)/tasks/assign/page.tsx`, change:
|
||
```typescript
|
||
prisma.client.findMany({
|
||
select: { id: true, name: true },
|
||
orderBy: { name: 'asc' },
|
||
}),
|
||
```
|
||
to:
|
||
```typescript
|
||
prisma.client.findMany({
|
||
select: { id: true, name: true, claimsAdvocateId: true },
|
||
orderBy: { name: 'asc' },
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: a new error will appear at this point because `BulkAssignClientProps.clients` (in `page-client.tsx`) doesn't yet declare `claimsAdvocateId` — this is expected and resolved by Task 7. Confirm the error is exactly this shape mismatch and nothing else.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add "src/app/(dashboard)/tasks/assign/page.tsx"
|
||
git commit -m "feat(tasks): include claimsAdvocateId in the client list for the assign page"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Frontend — mode toggle, per-client picker, preview dialog
|
||
|
||
**Files:**
|
||
- Modify: `src/app/(dashboard)/tasks/assign/page-client.tsx` (imports, types, state, handlers, JSX)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GenerateAndAssignResult` shape from Task 3/5 (`{ dryRun, clientsFound, totalEstimatedTasks, advocateName, clients, tasksCreated, tasksAssigned }`); `claimsAdvocateId` field added to `clients` prop in Task 6.
|
||
- Produces: no new exports (this is the leaf UI component).
|
||
|
||
- [ ] **Step 1: Update imports**
|
||
|
||
At the top of `src/app/(dashboard)/tasks/assign/page-client.tsx`, add:
|
||
```typescript
|
||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog'
|
||
```
|
||
|
||
- [ ] **Step 2: Update types**
|
||
|
||
Replace:
|
||
```typescript
|
||
interface SimpleClient { id: string; name: string }
|
||
```
|
||
with:
|
||
```typescript
|
||
interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null }
|
||
```
|
||
|
||
Replace the `GenResult` interface:
|
||
```typescript
|
||
interface GenResult {
|
||
tasksCreated: number
|
||
tasksAssigned: number
|
||
clientsFound: number
|
||
groupsProcessed: number
|
||
advocateName: string | null
|
||
message?: string
|
||
}
|
||
```
|
||
with:
|
||
```typescript
|
||
interface ClientTaskSummary {
|
||
id: string
|
||
name: string
|
||
estimatedTasks: number
|
||
}
|
||
|
||
interface GenResult {
|
||
dryRun: boolean
|
||
clientsFound: number
|
||
totalEstimatedTasks: number
|
||
advocateName: string | null
|
||
clients: ClientTaskSummary[]
|
||
tasksCreated: number
|
||
tasksAssigned: number
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add mode/preview state**
|
||
|
||
In the `BulkAssignClient` function body, replace:
|
||
```typescript
|
||
// Generate & assign state
|
||
const [genAdvocate, setGenAdvocate] = useState('')
|
||
const [genDesignation, setGenDesignation] = useState('')
|
||
const [generating, setGenerating] = useState(false)
|
||
const [genResult, setGenResult] = useState<GenResult | null>(null)
|
||
```
|
||
with:
|
||
```typescript
|
||
// Generate & assign state
|
||
const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation')
|
||
const [genAdvocate, setGenAdvocate] = useState('')
|
||
const [genDesignation, setGenDesignation] = useState('')
|
||
const [genClientId, setGenClientId] = useState('')
|
||
const [previewing, setPreviewing] = useState(false)
|
||
const [generating, setGenerating] = useState(false)
|
||
const [preview, setPreview] = useState<GenResult | null>(null)
|
||
const [genResult, setGenResult] = useState<GenResult | null>(null)
|
||
```
|
||
|
||
- [ ] **Step 4: Replace `handleGenerateAndAssign` with preview + confirm handlers**
|
||
|
||
Replace the entire `handleGenerateAndAssign` function:
|
||
```typescript
|
||
const handleGenerateAndAssign = async () => {
|
||
if (!genAdvocate || !genDesignation) return
|
||
setGenerating(true)
|
||
setGenResult(null)
|
||
try {
|
||
const res = await fetch('/api/tasks/generate-and-assign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ advocateId: genAdvocate, designationId: genDesignation }),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.error)
|
||
setGenResult(data)
|
||
if (data.tasksCreated > 0) {
|
||
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
|
||
fetchTasks()
|
||
} else if (data.clientsFound === 0) {
|
||
toast.info('No clients found with this advocate and designation')
|
||
} else {
|
||
toast.info(data.message || 'No new tasks to generate')
|
||
}
|
||
} catch (err: any) {
|
||
toast.error(err.message || 'Failed to generate tasks')
|
||
} finally {
|
||
setGenerating(false)
|
||
}
|
||
}
|
||
```
|
||
with:
|
||
```typescript
|
||
const buildGenBody = (dryRun: boolean) =>
|
||
genMode === 'client'
|
||
? { clientId: genClientId, dryRun }
|
||
: { advocateId: genAdvocate, designationId: genDesignation, dryRun }
|
||
|
||
const handlePreview = async () => {
|
||
setPreviewing(true)
|
||
setGenResult(null)
|
||
try {
|
||
const res = await fetch('/api/tasks/generate-and-assign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(buildGenBody(true)),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.error)
|
||
if (data.totalEstimatedTasks === 0) {
|
||
toast.info(
|
||
data.clientsFound === 0
|
||
? 'No clients found matching this selection'
|
||
: 'No new tasks to generate — all tasks may already exist'
|
||
)
|
||
return
|
||
}
|
||
setPreview(data)
|
||
} catch (err: any) {
|
||
toast.error(err.message || 'Failed to preview task generation')
|
||
} finally {
|
||
setPreviewing(false)
|
||
}
|
||
}
|
||
|
||
const handleConfirmGenerate = async () => {
|
||
setGenerating(true)
|
||
try {
|
||
const res = await fetch('/api/tasks/generate-and-assign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(buildGenBody(false)),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.error)
|
||
setGenResult(data)
|
||
setPreview(null)
|
||
if (data.tasksCreated > 0) {
|
||
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
|
||
fetchTasks()
|
||
} else {
|
||
toast.info('No new tasks to generate')
|
||
}
|
||
} catch (err: any) {
|
||
toast.error(err.message || 'Failed to generate tasks')
|
||
} finally {
|
||
setGenerating(false)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Replace the "Generate & Assign card" JSX**
|
||
|
||
Replace the `<CardContent>` block inside the "Generate & Assign card" (currently spanning the advocate/designation selects, the button, and the result paragraph — everything between `<CardContent>` and its matching `</CardContent>` right before `{/* Filter bar */}`):
|
||
```tsx
|
||
<CardContent>
|
||
<div className="flex items-end gap-3 flex-wrap">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<p className="text-sm font-medium mb-1.5">Claims Advocate</p>
|
||
<Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select advocate..." />
|
||
</SelectTrigger>
|
||
<UserSelectContent
|
||
users={users}
|
||
prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
|
||
/>
|
||
</Select>
|
||
</div>
|
||
<div className="flex-1 min-w-[200px]">
|
||
<p className="text-sm font-medium mb-1.5">Designation</p>
|
||
<Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select designation..." />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_none">Select designation</SelectItem>
|
||
{designations.map((d) => (
|
||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button onClick={handleGenerateAndAssign} disabled={!genAdvocate || !genDesignation || generating}>
|
||
{generating ? 'Generating...' : 'Generate & Assign'}
|
||
</Button>
|
||
</div>
|
||
{genResult && (
|
||
<p className="mt-3 text-sm text-muted-foreground">
|
||
{genResult.tasksCreated > 0
|
||
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
|
||
: genResult.clientsFound === 0
|
||
? `No clients found assigned to this advocate with that designation.`
|
||
: genResult.message || 'No new tasks to generate — all tasks may already exist.'}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
```
|
||
with:
|
||
```tsx
|
||
<CardContent>
|
||
<Tabs
|
||
value={genMode}
|
||
onValueChange={(v) => { setGenMode(v as 'advocate-designation' | 'client'); setGenResult(null) }}
|
||
>
|
||
<TabsList>
|
||
<TabsTrigger value="advocate-designation">By Advocate + Designation</TabsTrigger>
|
||
<TabsTrigger value="client">By Client</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
|
||
{genMode === 'advocate-designation' ? (
|
||
<div className="flex items-end gap-3 flex-wrap mt-3">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<p className="text-sm font-medium mb-1.5">Claims Advocate</p>
|
||
<Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select advocate..." />
|
||
</SelectTrigger>
|
||
<UserSelectContent
|
||
users={users}
|
||
prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
|
||
/>
|
||
</Select>
|
||
</div>
|
||
<div className="flex-1 min-w-[200px]">
|
||
<p className="text-sm font-medium mb-1.5">Designation</p>
|
||
<Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select designation..." />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_none">Select designation</SelectItem>
|
||
{designations.map((d) => (
|
||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button onClick={handlePreview} disabled={!genAdvocate || !genDesignation || previewing}>
|
||
{previewing ? 'Checking...' : 'Preview'}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-end gap-3 flex-wrap mt-3">
|
||
<div className="flex-1 min-w-[240px]">
|
||
<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 && (
|
||
<p className="text-sm text-destructive mt-1.5">
|
||
This client has no claims advocate assigned. Set one before generating tasks.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
onClick={handlePreview}
|
||
disabled={!genClientId || !clients.find((c) => c.id === genClientId)?.claimsAdvocateId || previewing}
|
||
>
|
||
{previewing ? 'Checking...' : 'Preview'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{genResult && (
|
||
<p className="mt-3 text-sm text-muted-foreground">
|
||
{genResult.tasksCreated > 0
|
||
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
|
||
: 'No new tasks to generate — all tasks may already exist.'}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
```
|
||
|
||
- [ ] **Step 6: Add the preview confirmation dialog**
|
||
|
||
Immediately after the closing `</Card>` of the "Generate & Assign card" (right before the `{/* Filter bar */}` comment), add:
|
||
```tsx
|
||
<AlertDialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null) }}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>
|
||
Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}?
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s)
|
||
and assign them to {preview?.advocateName}.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<div className="max-h-64 overflow-y-auto rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Client</TableHead>
|
||
<TableHead className="text-right">Tasks</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{preview?.clients.map((c) => (
|
||
<TableRow key={c.id}>
|
||
<TableCell>{c.name}</TableCell>
|
||
<TableCell className="text-right">{c.estimatedTasks}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleConfirmGenerate} disabled={generating}>
|
||
{generating ? 'Generating...' : 'Confirm'}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
```
|
||
|
||
- [ ] **Step 7: Typecheck**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: no errors referencing `page-client.tsx` or `page.tsx` (the mismatch introduced in Task 6 is now resolved by the updated `SimpleClient` interface in Step 2 of this task).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add "src/app/(dashboard)/tasks/assign/page-client.tsx"
|
||
git commit -m "feat(tasks): add per-client mode and preview-before-generate to Generate & Assign"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Build, deploy, and manually verify end-to-end
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [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.
|
||
|
||
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
|
||
cd /opt/stacks/horizon
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- **Spec coverage:** every decision row in the spec (preview scope = this page only, per-client assignee = client's own advocate, separate mode toggle, summary-count preview detail, `dryRun`-on-existing-endpoint mechanism) is implemented by Tasks 2–7. Retention/testing plan items from the spec are covered by Tasks 2–4 (core module tests) and Task 8 (manual E2E + full suite run).
|
||
- **Placeholder scan:** no TBD/TODO; every step has complete, runnable code.
|
||
- **Type consistency:** `GenerateAndAssignResult` (Task 3) and the frontend `GenResult` (Task 7) use identical field names (`dryRun`, `clientsFound`, `totalEstimatedTasks`, `advocateName`, `clients`, `tasksCreated`, `tasksAssigned`) and `ClientTaskSummary`/`ClientTaskSummary`-shaped objects (`id`, `name`, `estimatedTasks`) match on both sides.
|