Compare commits
11 commits
fb3fe67164
...
347e4b342b
| Author | SHA1 | Date | |
|---|---|---|---|
| 347e4b342b | |||
| db7ea9fc86 | |||
| 2906f31502 | |||
| b8ea94e37b | |||
| adc3d9bfa7 | |||
| 5daab12d98 | |||
| a1206f9515 | |||
| 06842f9031 | |||
| fd38e0c148 | |||
| 64f8b71603 | |||
| d8aaea33e6 |
10 changed files with 1467 additions and 330 deletions
|
|
@ -1274,12 +1274,14 @@ git commit -m "feat(tasks): add per-client mode and preview-before-generate to G
|
||||||
|
|
||||||
**Files:** none (verification only)
|
**Files:** none (verification only)
|
||||||
|
|
||||||
- [ ] **Step 1: Run the full test suite**
|
- [x] **Step 1: Run the full test suite**
|
||||||
|
|
||||||
Run: `npx jest`
|
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.
|
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:
|
Run:
|
||||||
```bash
|
```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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { UserSelectContent } from '@/components/ui/user-select-content'
|
import { UserSelectContent } from '@/components/ui/user-select-content'
|
||||||
|
import { ClientSelect } from '@/components/clients/client-select'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -22,17 +23,26 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Users, Filter, CheckSquare, Wand2, MessageSquare, Pencil } from 'lucide-react'
|
import { Users, Filter, CheckSquare, Wand2, MessageSquare, Pencil } from 'lucide-react'
|
||||||
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||||
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
|
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
|
||||||
|
|
||||||
interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null }
|
interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null }
|
||||||
interface SimpleClient { id: string; name: string }
|
|
||||||
interface SimpleDesignation { id: string; name: string }
|
interface SimpleDesignation { id: string; name: string }
|
||||||
|
|
||||||
interface BulkAssignClientProps {
|
interface BulkAssignClientProps {
|
||||||
users: SimpleUser[]
|
users: SimpleUser[]
|
||||||
clients: SimpleClient[]
|
|
||||||
designations: SimpleDesignation[]
|
designations: SimpleDesignation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,16 +62,59 @@ const DEPT_OPTIONS = [
|
||||||
{ value: 'OTHER', label: 'Other' },
|
{ value: 'OTHER', label: 'Other' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface GenResult {
|
interface TaskPreviewItem {
|
||||||
tasksCreated: number
|
title: string
|
||||||
tasksAssigned: number
|
department: string
|
||||||
clientsFound: number
|
dueDate: string
|
||||||
groupsProcessed: number
|
priority: string
|
||||||
advocateName: string | null
|
context: string
|
||||||
message?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) {
|
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 {
|
||||||
|
dryRun: boolean
|
||||||
|
clientsFound: number
|
||||||
|
totalEstimatedTasks: number
|
||||||
|
advocateName: string | null
|
||||||
|
clients: ClientTaskSummary[]
|
||||||
|
tasksCreated: number
|
||||||
|
tasksAssigned: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkAssignClient({ users, designations }: BulkAssignClientProps) {
|
||||||
const [tasks, setTasks] = useState<any[]>([])
|
const [tasks, setTasks] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
@ -70,9 +123,14 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
|
||||||
const [editingTask, setEditingTask] = useState<any | null>(null)
|
const [editingTask, setEditingTask] = useState<any | null>(null)
|
||||||
|
|
||||||
// Generate & assign state
|
// Generate & assign state
|
||||||
|
const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation')
|
||||||
const [genAdvocate, setGenAdvocate] = useState('')
|
const [genAdvocate, setGenAdvocate] = useState('')
|
||||||
const [genDesignation, setGenDesignation] = 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 [generating, setGenerating] = useState(false)
|
||||||
|
const [preview, setPreview] = useState<GenResult | null>(null)
|
||||||
const [genResult, setGenResult] = useState<GenResult | null>(null)
|
const [genResult, setGenResult] = useState<GenResult | null>(null)
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
|
|
@ -140,26 +198,56 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateAndAssign = async () => {
|
const buildGenBody = (dryRun: boolean) =>
|
||||||
if (!genAdvocate || !genDesignation) return
|
genMode === 'client'
|
||||||
setGenerating(true)
|
? { clientId: genClientId, dryRun }
|
||||||
|
: { advocateId: genAdvocate, designationId: genDesignation, dryRun }
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setPreviewing(true)
|
||||||
setGenResult(null)
|
setGenResult(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tasks/generate-and-assign', {
|
const res = await fetch('/api/tasks/generate-and-assign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ advocateId: genAdvocate, designationId: genDesignation }),
|
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 (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
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()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error)
|
if (!res.ok) throw new Error(data.error)
|
||||||
setGenResult(data)
|
setGenResult(data)
|
||||||
|
setPreview(null)
|
||||||
if (data.tasksCreated > 0) {
|
if (data.tasksCreated > 0) {
|
||||||
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
|
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
|
||||||
fetchTasks()
|
fetchTasks()
|
||||||
} else if (data.clientsFound === 0) {
|
|
||||||
toast.info('No clients found with this advocate and designation')
|
|
||||||
} else {
|
} else {
|
||||||
toast.info(data.message || 'No new tasks to generate')
|
toast.info('No new tasks to generate')
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.message || 'Failed to generate tasks')
|
toast.error(err.message || 'Failed to generate tasks')
|
||||||
|
|
@ -194,49 +282,157 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
<Tabs
|
||||||
<div className="flex-1 min-w-[200px]">
|
value={genMode}
|
||||||
<p className="text-sm font-medium mb-1.5">Claims Advocate</p>
|
onValueChange={(v) => { setGenMode(v as 'advocate-designation' | 'client'); setGenResult(null) }}
|
||||||
<Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
|
>
|
||||||
<SelectTrigger>
|
<TabsList>
|
||||||
<SelectValue placeholder="Select advocate..." />
|
<TabsTrigger value="advocate-designation">By Advocate + Designation</TabsTrigger>
|
||||||
</SelectTrigger>
|
<TabsTrigger value="client">By Client</TabsTrigger>
|
||||||
<UserSelectContent
|
</TabsList>
|
||||||
users={users}
|
</Tabs>
|
||||||
prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
|
|
||||||
|
{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="w-[320px]">
|
||||||
|
<p className="text-sm font-medium mb-1.5">Client</p>
|
||||||
|
<ClientSelect
|
||||||
|
value={genClientId}
|
||||||
|
onChange={(client) => {
|
||||||
|
setGenClientId(client?.id || '')
|
||||||
|
setGenClientAdvocateId(client?.claimsAdvocateId ?? null)
|
||||||
|
setGenResult(null)
|
||||||
|
}}
|
||||||
|
placeholder="Search clients..."
|
||||||
/>
|
/>
|
||||||
</Select>
|
{genClientId && !genClientAdvocateId && (
|
||||||
|
<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 || !genClientAdvocateId || previewing}
|
||||||
|
>
|
||||||
|
{previewing ? 'Checking...' : 'Preview'}
|
||||||
|
</Button>
|
||||||
</div>
|
</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 && (
|
{genResult && (
|
||||||
<p className="mt-3 text-sm text-muted-foreground">
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
{genResult.tasksCreated > 0
|
{genResult.tasksCreated > 0
|
||||||
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
|
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
|
||||||
: genResult.clientsFound === 0
|
: 'No new tasks to generate — all tasks may already exist.'}
|
||||||
? `No clients found assigned to this advocate with that designation.`
|
|
||||||
: genResult.message || 'No new tasks to generate — all tasks may already exist.'}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null) }}>
|
||||||
|
<AlertDialogContent
|
||||||
|
className="sm:!max-w-[80vw]"
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (generating) event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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-96 overflow-y-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Task</TableHead>
|
||||||
|
<TableHead>Context</TableHead>
|
||||||
|
<TableHead>Department</TableHead>
|
||||||
|
<TableHead>Due Date</TableHead>
|
||||||
|
<TableHead>Priority</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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}>
|
||||||
|
{generating ? 'Generating...' : 'Confirm'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -247,13 +443,11 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Select value={clientFilter} onValueChange={setClientFilter}>
|
<ClientSelect
|
||||||
<SelectTrigger><SelectValue placeholder="All clients" /></SelectTrigger>
|
value={clientFilter === '_all' ? '' : clientFilter}
|
||||||
<SelectContent>
|
onChange={(client) => setClientFilter(client?.id || '_all')}
|
||||||
<SelectItem value="_all">All clients</SelectItem>
|
placeholder="All clients"
|
||||||
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={designationFilter} onValueChange={setDesignationFilter}>
|
<Select value={designationFilter} onValueChange={setDesignationFilter}>
|
||||||
<SelectTrigger><SelectValue placeholder="All designations" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="All designations" /></SelectTrigger>
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,12 @@ export default async function BulkAssignPage() {
|
||||||
redirect('/tasks')
|
redirect('/tasks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const [users, clients, designations] = await Promise.all([
|
const [users, designations] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { id: true, displayName: true, email: true, department: true },
|
select: { id: true, displayName: true, email: true, department: true },
|
||||||
orderBy: { displayName: 'asc' },
|
orderBy: { displayName: 'asc' },
|
||||||
}),
|
}),
|
||||||
prisma.client.findMany({
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
}),
|
|
||||||
prisma.designation.findMany({
|
prisma.designation.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
|
|
@ -34,7 +30,7 @@ export default async function BulkAssignPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8">
|
<div className="container mx-auto py-8">
|
||||||
<BulkAssignClient users={users} clients={clients} designations={designations} />
|
<BulkAssignClient users={users} designations={designations} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { isRelevantDueDate } from '@/lib/sync/task-due-date'
|
||||||
* POST /api/policy-groups/[id]/generate-tasks
|
* POST /api/policy-groups/[id]/generate-tasks
|
||||||
* Generate tasks from active templates using the group's renewalDate.
|
* Generate tasks from active templates using the group's renewalDate.
|
||||||
* One task per template per group (skips already-generated ones).
|
* 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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -25,6 +26,7 @@ export async function POST(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
const dryRun = new URL(request.url).searchParams.get('dryRun') === 'true'
|
||||||
|
|
||||||
const group = await prisma.policyGroup.findUnique({
|
const group = await prisma.policyGroup.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -71,7 +73,9 @@ export async function POST(
|
||||||
|
|
||||||
if (templates.length === 0) {
|
if (templates.length === 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
dryRun,
|
||||||
created: 0,
|
created: 0,
|
||||||
|
estimatedTasks: 0,
|
||||||
skipped: alreadyGeneratedTemplateIds.length,
|
skipped: alreadyGeneratedTemplateIds.length,
|
||||||
message: 'No new templates to generate tasks from',
|
message: 'No new templates to generate tasks from',
|
||||||
})
|
})
|
||||||
|
|
@ -104,12 +108,28 @@ export async function POST(
|
||||||
|
|
||||||
if (tasksToCreate.length === 0) {
|
if (tasksToCreate.length === 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
dryRun,
|
||||||
created: 0,
|
created: 0,
|
||||||
|
estimatedTasks: 0,
|
||||||
skipped: alreadyGeneratedTemplateIds.length,
|
skipped: alreadyGeneratedTemplateIds.length,
|
||||||
message: 'No new templates produced a relevant (today-or-later) due date',
|
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({
|
const result = await prisma.task.createMany({
|
||||||
data: tasksToCreate as any[],
|
data: tasksToCreate as any[],
|
||||||
})
|
})
|
||||||
|
|
@ -129,6 +149,7 @@ export async function POST(
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
dryRun: false,
|
||||||
created: result.count,
|
created: result.count,
|
||||||
skipped: alreadyGeneratedTemplateIds.length,
|
skipped: alreadyGeneratedTemplateIds.length,
|
||||||
templates: tasksToCreate.map((t) => ({ name: t.title, dueDate: t.dueDate })),
|
templates: tasksToCreate.map((t) => ({ name: t.title, dueDate: t.dueDate })),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/db'
|
import {
|
||||||
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
|
parseGenerateAndAssignTarget,
|
||||||
|
runGenerateAndAssign,
|
||||||
|
GenerateAndAssignError,
|
||||||
|
} from '@/lib/tasks/generate-and-assign-core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/tasks/generate-and-assign
|
* POST /api/tasks/generate-and-assign
|
||||||
* Find clients matching the given advocate + designation, generate tasks from
|
* Generate tasks from active templates for either (a) every client matching
|
||||||
* active templates, and assign them to the advocate.
|
* an advocate + designation, or (b) one specific client, and assign them to
|
||||||
* Body: { advocateId: string, designationId: string }
|
* 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.
|
* Requires Admin or Manager role.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|
@ -24,260 +29,18 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { advocateId, designationId } = body
|
const { target, dryRun } = parseGenerateAndAssignTarget(body)
|
||||||
|
|
||||||
if (!advocateId || !designationId) {
|
const result = await runGenerateAndAssign(target, {
|
||||||
return NextResponse.json(
|
dryRun,
|
||||||
{ error: 'advocateId and designationId are required' },
|
actorUserId: (session.user as any).id,
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const advocate = await prisma.user.findUnique({
|
|
||||||
where: { id: advocateId },
|
|
||||||
select: { id: true, displayName: true, isActive: true },
|
|
||||||
})
|
|
||||||
if (!advocate || !advocate.isActive) {
|
|
||||||
return NextResponse.json({ error: 'Advocate not found or inactive' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clients assigned to this advocate with this designation
|
|
||||||
const clients = await prisma.client.findMany({
|
|
||||||
where: {
|
|
||||||
claimsAdvocateId: advocateId,
|
|
||||||
OR: [{ designationId }, { designation2Id: designationId }],
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (clients.length === 0) {
|
return NextResponse.json(result)
|
||||||
return NextResponse.json({
|
} catch (error: any) {
|
||||||
tasksCreated: 0,
|
if (error instanceof GenerateAndAssignError) {
|
||||||
tasksAssigned: 0,
|
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||||
clientsFound: 0,
|
|
||||||
groupsProcessed: 0,
|
|
||||||
advocateName: advocate.displayName,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIds = clients.map((c) => c.id)
|
|
||||||
|
|
||||||
const allTemplates = await prisma.taskTemplate.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true,
|
|
||||||
OR: [{ designationId: null }, { designationId }],
|
|
||||||
},
|
|
||||||
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (allTemplates.length === 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
tasksCreated: 0,
|
|
||||||
tasksAssigned: 0,
|
|
||||||
clientsFound: clients.length,
|
|
||||||
groupsProcessed: 0,
|
|
||||||
advocateName: advocate.displayName,
|
|
||||||
message: 'No active templates found for this designation',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupTemplates = allTemplates.filter((t: any) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP')
|
|
||||||
const policyTemplates = allTemplates.filter((t: any) => t.level === 'BOTH' || t.level === 'POLICY')
|
|
||||||
let tasksCreated = 0
|
|
||||||
let tasksAssigned = 0
|
|
||||||
let groupsProcessed = 0
|
|
||||||
|
|
||||||
// ── Policy groups with no template-generated tasks yet ──────────────────
|
|
||||||
const groups = await prisma.policyGroup.findMany({
|
|
||||||
where: {
|
|
||||||
clientId: { in: 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: (session.user as any).id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((task) => isRelevantDueDate(task.dueDate))
|
|
||||||
|
|
||||||
if (tasksToCreate.length === 0) 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: advocateId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
tasksAssigned += assigned.count
|
|
||||||
}
|
|
||||||
groupsProcessed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ungrouped policies with no template-generated tasks yet ─────────────
|
|
||||||
const policies = await prisma.policy.findMany({
|
|
||||||
where: {
|
|
||||||
clientId: { in: clientIds },
|
|
||||||
policyGroupId: null,
|
|
||||||
tasks: { none: { templateId: { not: null } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const policy of policies) {
|
|
||||||
const anchorDate = new Date(policy.expirationDate)
|
|
||||||
anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 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: (session.user as any).id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((task) => isRelevantDueDate(task.dueDate))
|
|
||||||
|
|
||||||
if (tasksToCreate.length === 0) 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: advocateId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
tasksAssigned += assigned.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CLIENT-level tasks (once per client) ────────────────────────────────
|
|
||||||
const clientTemplates = allTemplates.filter((t: any) => t.level === 'CLIENT')
|
|
||||||
|
|
||||||
if (clientTemplates.length > 0) {
|
|
||||||
for (const clientId of 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
|
|
||||||
|
|
||||||
const created = 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: (session.user as any).id,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
tasksCreated++
|
|
||||||
|
|
||||||
const assigned = await prisma.taskAssignment.create({
|
|
||||||
data: { taskId: created.id, userId: advocateId },
|
|
||||||
})
|
|
||||||
tasksAssigned++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId: (session.user as any).id,
|
|
||||||
action: 'GENERATE_AND_ASSIGN_TASKS',
|
|
||||||
entityType: 'Task',
|
|
||||||
newValues: {
|
|
||||||
advocateId,
|
|
||||||
advocateName: advocate.displayName,
|
|
||||||
designationId,
|
|
||||||
clientsFound: clients.length,
|
|
||||||
tasksCreated,
|
|
||||||
tasksAssigned,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
tasksCreated,
|
|
||||||
tasksAssigned,
|
|
||||||
clientsFound: clients.length,
|
|
||||||
groupsProcessed,
|
|
||||||
advocateName: advocate.displayName,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Generate and assign error:', error)
|
console.error('Generate and assign error:', error)
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
ondeck/src/components/clients/client-select.tsx
Normal file
144
ondeck/src/components/clients/client-select.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,14 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||||
|
|
||||||
interface Policy {
|
interface Policy {
|
||||||
|
|
@ -63,6 +71,13 @@ interface PolicyGroup {
|
||||||
_count: { tasks: number }
|
_count: { tasks: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GenerateTaskPreviewItem {
|
||||||
|
title: string
|
||||||
|
department: string
|
||||||
|
dueDate: string
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
interface PolicyGroupManagerProps {
|
interface PolicyGroupManagerProps {
|
||||||
clientId: string
|
clientId: string
|
||||||
initialGroups: PolicyGroup[]
|
initialGroups: PolicyGroup[]
|
||||||
|
|
@ -97,6 +112,13 @@ export function PolicyGroupManager({
|
||||||
const [deleteTarget, setDeleteTarget] = useState<PolicyGroup | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<PolicyGroup | null>(null)
|
||||||
const [cancelTasks, setCancelTasks] = useState(false)
|
const [cancelTasks, setCancelTasks] = useState(false)
|
||||||
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
|
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 [adhocGroupId, setAdhocGroupId] = useState<string | null>(null)
|
||||||
const [sessionUserId, setSessionUserId] = useState('')
|
const [sessionUserId, setSessionUserId] = useState('')
|
||||||
// Policy movement confirmation
|
// Policy movement confirmation
|
||||||
|
|
@ -313,10 +335,39 @@ export function PolicyGroupManager({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateTasks = async (groupId: string) => {
|
const handlePreviewGenerate = async (groupId: string) => {
|
||||||
setGeneratingFor(groupId)
|
setGeneratingFor(groupId)
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -324,7 +375,7 @@ export function PolicyGroupManager({
|
||||||
|
|
||||||
setGroups((prev) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) =>
|
prev.map((g) =>
|
||||||
g.id === groupId
|
g.id === generatePreview.groupId
|
||||||
? { ...g, _count: { tasks: g._count.tasks + data.created } }
|
? { ...g, _count: { tasks: g._count.tasks + data.created } }
|
||||||
: g
|
: g
|
||||||
)
|
)
|
||||||
|
|
@ -336,10 +387,11 @@ export function PolicyGroupManager({
|
||||||
toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`)
|
toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`)
|
||||||
onTasksGenerated?.(data.created)
|
onTasksGenerated?.(data.created)
|
||||||
}
|
}
|
||||||
|
setGeneratePreview(null)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.message || 'Failed to generate tasks')
|
toast.error(err.message || 'Failed to generate tasks')
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingFor(null)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,11 +470,11 @@ export function PolicyGroupManager({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleGenerateTasks(group.id)}
|
onClick={() => handlePreviewGenerate(group.id)}
|
||||||
disabled={generatingFor === group.id}
|
disabled={generatingFor === group.id}
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5 mr-1" />
|
<Zap className="h-3.5 w-3.5 mr-1" />
|
||||||
{generatingFor === group.id ? 'Generating...' : 'Generate Tasks'}
|
{generatingFor === group.id ? 'Checking...' : 'Generate Tasks'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -733,6 +785,57 @@ export function PolicyGroupManager({
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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 "{generatePreview?.groupName}".
|
||||||
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { isRelevantDueDate } from './task-due-date'
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
/** Designation IDs (primary + secondary) of a client, with nulls dropped. */
|
/** Designation IDs (primary + secondary) of a client, with nulls dropped. */
|
||||||
function designationIdsOf(entity: {
|
export function designationIdsOf(entity: {
|
||||||
designationId: string | null
|
designationId: string | null
|
||||||
designation2Id: string | null
|
designation2Id: string | null
|
||||||
}): string[] {
|
}): string[] {
|
||||||
|
|
@ -16,7 +16,7 @@ function designationIdsOf(entity: {
|
||||||
* Prisma `OR` clause matching templates that apply to a client: templates with
|
* Prisma `OR` clause matching templates that apply to a client: templates with
|
||||||
* no designation, plus templates scoped to one of the client's designations.
|
* no designation, plus templates scoped to one of the client's designations.
|
||||||
*/
|
*/
|
||||||
function designationFilter(ids: string[]) {
|
export function designationFilter(ids: string[]) {
|
||||||
return [
|
return [
|
||||||
{ designationId: null },
|
{ designationId: null },
|
||||||
...(ids.length > 0 ? [{ designationId: { in: ids } }] : []),
|
...(ids.length > 0 ? [{ designationId: { in: ids } }] : []),
|
||||||
|
|
|
||||||
434
ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Normal file
434
ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
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, parseGenerateAndAssignTarget, GenerateAndAssignError } 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', name: 'Renewal Group', 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).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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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', name: 'Renewal Group', 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not write an audit log and returns zero counts when no templates match', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
const result = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
expect(mockAuditLogCreate).not.toHaveBeenCalled()
|
||||||
|
expect(result.totalEstimatedTasks).toBe(0)
|
||||||
|
expect(result.tasksCreated).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and counts a POLICY-level task for an ungrouped policy', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-policy',
|
||||||
|
name: 'Review policy renewal',
|
||||||
|
description: null,
|
||||||
|
department: 'Personal Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'POLICY',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
mockPolicyFindMany.mockResolvedValue([
|
||||||
|
{ id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') },
|
||||||
|
])
|
||||||
|
mockTaskCreateMany.mockResolvedValue({ count: 1 })
|
||||||
|
mockTaskFindMany.mockResolvedValue([{ id: 'task-policy-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(dryRunResult.totalEstimatedTasks).toBe(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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and counts a CLIENT-level task via prisma.task.create', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-client',
|
||||||
|
name: 'Send client renewal letter',
|
||||||
|
description: null,
|
||||||
|
department: 'Commercial Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'CLIENT',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
mockClientFindUnique.mockResolvedValue({
|
||||||
|
policyGroups: [{ renewalDate: new Date('2026-08-01') }],
|
||||||
|
policies: [],
|
||||||
|
})
|
||||||
|
mockTaskFindFirst.mockResolvedValue(null)
|
||||||
|
mockTaskCreate.mockResolvedValue({ id: 'task-client-1' })
|
||||||
|
mockTaskAssignmentCreate.mockResolvedValue({})
|
||||||
|
|
||||||
|
const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||||||
|
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
expect(dryRunResult.totalEstimatedTasks).toBe(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)
|
||||||
|
expect(realResult.tasksAssigned).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums estimatedTasks for a client that gets tasks from both group- and policy-level templates', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-group',
|
||||||
|
name: 'Prepare loss summary',
|
||||||
|
description: null,
|
||||||
|
department: 'Commercial Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'RENEWAL_GROUP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'template-policy',
|
||||||
|
name: 'Review policy renewal',
|
||||||
|
description: null,
|
||||||
|
department: 'Personal Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'POLICY',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
mockPolicyGroupFindMany.mockResolvedValue([
|
||||||
|
{ 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') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
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', name: 'Renewal Group', 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
474
ondeck/src/lib/tasks/generate-and-assign-core.ts
Normal file
474
ondeck/src/lib/tasks/generate-and-assign-core.ts
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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>()
|
||||||
|
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
|
||||||
|
|
||||||
|
const allTemplates = await prisma.taskTemplate.findMany({
|
||||||
|
where: { isActive: true, OR: designationFilter(resolved.templateDesignationIds) },
|
||||||
|
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allTemplates.length === 0) {
|
||||||
|
return {
|
||||||
|
dryRun: options.dryRun,
|
||||||
|
clientsFound: resolved.clientIds.length,
|
||||||
|
totalEstimatedTasks: 0,
|
||||||
|
advocateName: resolved.advocateName,
|
||||||
|
clients: [],
|
||||||
|
tasksCreated: 0,
|
||||||
|
tasksAssigned: 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')
|
||||||
|
|
||||||
|
// 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 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = applicableTemplates
|
||||||
|
.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)
|
||||||
|
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 })
|
||||||
|
tasksCreated += created.count
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const newTasks = await prisma.task.findMany({
|
||||||
|
where: { policyGroupId: group.id, templateId: { in: applicableTemplates.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 },
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = applicableTemplates
|
||||||
|
.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)
|
||||||
|
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 })
|
||||||
|
tasksCreated += created.count
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const newTasks = await prisma.task.findMany({
|
||||||
|
where: { policyId: policy.id, templateId: { in: applicableTemplates.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)
|
||||||
|
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({
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue