diff --git a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md index 06d3b5a..1e5796f 100644 --- a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md +++ b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md @@ -1274,12 +1274,14 @@ git commit -m "feat(tasks): add per-client mode and preview-before-generate to G **Files:** none (verification only) -- [ ] **Step 1: Run the full test suite** +- [x] **Step 1: Run the full test suite** Run: `npx jest` Expected: the new `generate-and-assign-core.test.ts` (13 tests) and `auto-generate.test.ts` (4 tests) pass. The three pre-existing unrelated failures (`auth.test.ts`, `mappers.test.ts`, `renewal-group-recommendations.test.ts`) are expected and out of scope — confirmed pre-existing in an earlier session. -- [ ] **Step 2: Rebuild and restart the app container** +Confirmed 2026-07-18: 113 passed, 13 failed across exactly the 3 expected pre-existing suites (`auth.test.ts`, `mappers.test.ts`, `renewal-group-recommendations.test.ts`); `generate-and-assign-core.test.ts` and `auto-generate.test.ts` both fully green. + +- [x] **Step 2: Rebuild and restart the app container** Run: ```bash @@ -1288,15 +1290,21 @@ docker compose build horizon-app && docker compose up -d horizon-app ``` Expected: build succeeds; `docker compose logs --tail 20 horizon-app` shows `✓ Ready` with no errors. -- [ ] **Step 3: Manually verify the "By Advocate + Designation" preview flow** +Confirmed 2026-07-18: branch `task-gen-preview-and-per-client` fast-forward merged into `main`, image rebuilt, container recreated, logs show `✓ Ready in 321ms` with no errors. + +- [x] **Step 3: Manually verify the "By Advocate + Designation" preview flow** In a browser, sign in as an Admin, go to Tasks → Generate & Assign. Select an advocate + designation known to have at least one client with missing template tasks, click **Preview**. Confirm a dialog opens showing a total, client count, and a per-client breakdown table — and that no new tasks appear in the task list yet (nothing was written). Click **Cancel** and confirm no tasks were created (check via the "Total tasks" count elsewhere or the client's task list). Repeat, this time clicking **Confirm**, and verify tasks now appear and the dialog closes. -- [ ] **Step 4: Manually verify the "By Client" mode** +Confirmed 2026-07-18 via Playwright against localhost:3000, signed in as the local dev Admin account: Dawn Boland + Shape showed "Generate 24 tasks? ... across 6 client(s)" with a 3-row breakdown table (rows only list clients with `estimatedTasks > 0`, by design — see `generate-and-assign-core.ts`). Cancel left the DB task count unchanged (6950). Confirm created exactly 24 tasks (6950→6974) and closed the dialog. + +- [x] **Step 4: Manually verify the "By Client" mode** Switch to the **By Client** tab, pick a client whose `claimsAdvocateId` is set. Click **Preview**, confirm the dialog shows just that one client's row and a total matching it. Confirm. Then pick a client with no claims advocate set and confirm the **Preview** button is disabled with the "no claims advocate assigned" message showing. -- [ ] **Step 5: No commit for this task** — verification only, nothing to stage. +Confirmed 2026-07-18: "BEK TRANS GROUP INC" (advocate set, 0 existing tasks) previewed as "Generate 5 tasks?" with a single matching row; Confirm created exactly 5 tasks for that client. "1000 Howard Boulevard Partners, LP" (no advocate) showed the Preview button disabled with "This client has no claims advocate assigned. Set one before generating tasks." + +- [x] **Step 5: No commit for this task** — verification only, nothing to stage. --- diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx index 04218f2..9e51d3b 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from '@/components/ui/select' import { UserSelectContent } from '@/components/ui/user-select-content' +import { ClientSelect } from '@/components/clients/client-select' import { Table, TableBody, @@ -22,17 +23,26 @@ import { TableHeader, TableRow, } 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 { formatDate, formatRenewalDate } from '@/lib/utils' import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal' interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null } -interface SimpleClient { id: string; name: string } interface SimpleDesignation { id: string; name: string } interface BulkAssignClientProps { users: SimpleUser[] - clients: SimpleClient[] designations: SimpleDesignation[] } @@ -52,16 +62,59 @@ const DEPT_OPTIONS = [ { value: 'OTHER', label: 'Other' }, ] -interface GenResult { - tasksCreated: number - tasksAssigned: number - clientsFound: number - groupsProcessed: number - advocateName: string | null - message?: string +interface TaskPreviewItem { + title: string + department: string + dueDate: string + priority: string + context: 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([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState>(new Set()) @@ -70,9 +123,14 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli const [editingTask, setEditingTask] = useState(null) // Generate & assign state + const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation') const [genAdvocate, setGenAdvocate] = useState('') const [genDesignation, setGenDesignation] = useState('') + const [genClientId, setGenClientId] = useState('') + const [genClientAdvocateId, setGenClientAdvocateId] = useState(null) + const [previewing, setPreviewing] = useState(false) const [generating, setGenerating] = useState(false) + const [preview, setPreview] = useState(null) const [genResult, setGenResult] = useState(null) // Filters @@ -140,26 +198,56 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli } } - const handleGenerateAndAssign = async () => { - if (!genAdvocate || !genDesignation) return - setGenerating(true) + const buildGenBody = (dryRun: boolean) => + genMode === 'client' + ? { clientId: genClientId, dryRun } + : { advocateId: genAdvocate, designationId: genDesignation, dryRun } + + const handlePreview = async () => { + setPreviewing(true) setGenResult(null) try { const res = await fetch('/api/tasks/generate-and-assign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ 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() if (!res.ok) throw new Error(data.error) setGenResult(data) + setPreview(null) if (data.tasksCreated > 0) { toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`) fetchTasks() - } else if (data.clientsFound === 0) { - toast.info('No clients found with this advocate and designation') } else { - toast.info(data.message || 'No new tasks to generate') + toast.info('No new tasks to generate') } } catch (err: any) { toast.error(err.message || 'Failed to generate tasks') @@ -194,49 +282,157 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli -
-
-

Claims Advocate

- { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}> + + + + Select advocate} + /> + +
+
+

Designation

+ +
+ +
+ ) : ( +
+
+

Client

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

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

+ )} +
+
-
-

Designation

- -
- - + )} + {genResult && (

{genResult.tasksCreated > 0 ? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.` - : genResult.clientsFound === 0 - ? `No clients found assigned to this advocate with that designation.` - : genResult.message || 'No new tasks to generate — all tasks may already exist.'} + : 'No new tasks to generate — all tasks may already exist.'}

)}
+ { if (!open) setPreview(null) }}> + { + if (generating) event.preventDefault() + }} + > + + + Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}? + + + This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s) + and assign them to {preview?.advocateName}. + + +
+ + + + Client + Task + Context + Department + Due Date + Priority + + + + {preview?.clients.flatMap((c) => + c.tasks.map((t, i) => ( + + {c.name} + {t.title} + {t.context} + {t.department?.replace('_', ' ')} + {formatDate(t.dueDate)} + {t.priority} + + )) + )} + +
+
+ {preview && preview.clients.some((c) => breakdownLines(c.breakdown).length > 0) && ( +
+ {preview.clients.map((c) => { + const lines = breakdownLines(c.breakdown) + if (lines.length === 0) return null + return ( +
+ {preview.clients.length > 1 &&

{c.name}

} +
    + {lines.map((line, i) => ( +
  • {line}
  • + ))} +
+
+ ) + })} +
+ )} + + Cancel + + {generating ? 'Generating...' : 'Confirm'} + + +
+
+ {/* Filter bar */} @@ -247,13 +443,11 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
- + setClientFilter(client?.id || '_all')} + placeholder="All clients" + /> setQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {displayLabel || placeholder} + + )} +
+ {value && !open && ( + + )} + +
+
+ + {open && ( +
+ {loading && results.length === 0 ? ( +
Searching...
+ ) : results.length === 0 ? ( +
No clients found
+ ) : ( + results.map((c) => ( +
e.preventDefault()} + onClick={() => handleSelect(c)} + > + {c.name} +
+ )) + )} +
+ )} + + ) +} diff --git a/ondeck/src/components/clients/policy-group-manager.tsx b/ondeck/src/components/clients/policy-group-manager.tsx index 207059c..634560d 100644 --- a/ondeck/src/components/clients/policy-group-manager.tsx +++ b/ondeck/src/components/clients/policy-group-manager.tsx @@ -40,6 +40,14 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Checkbox } from '@/components/ui/checkbox' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' import { formatDate, formatRenewalDate } from '@/lib/utils' interface Policy { @@ -63,6 +71,13 @@ interface PolicyGroup { _count: { tasks: number } } +interface GenerateTaskPreviewItem { + title: string + department: string + dueDate: string + priority: string +} + interface PolicyGroupManagerProps { clientId: string initialGroups: PolicyGroup[] @@ -97,6 +112,13 @@ export function PolicyGroupManager({ const [deleteTarget, setDeleteTarget] = useState(null) const [cancelTasks, setCancelTasks] = useState(false) const [generatingFor, setGeneratingFor] = useState(null) + const [generatePreview, setGeneratePreview] = useState<{ + groupId: string + groupName: string + tasks: GenerateTaskPreviewItem[] + skipped: number + } | null>(null) + const [generating, setGenerating] = useState(false) const [adhocGroupId, setAdhocGroupId] = useState(null) const [sessionUserId, setSessionUserId] = useState('') // Policy movement confirmation @@ -313,10 +335,39 @@ export function PolicyGroupManager({ } } - const handleGenerateTasks = async (groupId: string) => { + const handlePreviewGenerate = async (groupId: string) => { setGeneratingFor(groupId) try { - const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks`, { + const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks?dryRun=true`, { + method: 'POST', + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to preview tasks') + + if (!data.estimatedTasks) { + toast.info(data.message || 'No new tasks to generate') + return + } + + setGeneratePreview({ + groupId, + groupName: groups.find((g) => g.id === groupId)?.name ?? 'this group', + tasks: data.tasks ?? [], + skipped: data.skipped ?? 0, + }) + } catch (err: any) { + toast.error(err.message || 'Failed to preview tasks') + } finally { + setGeneratingFor(null) + } + } + + const handleConfirmGenerate = async (event: React.MouseEvent) => { + event.preventDefault() + if (!generatePreview) return + setGenerating(true) + try { + const res = await fetch(`/api/policy-groups/${generatePreview.groupId}/generate-tasks`, { method: 'POST', }) const data = await res.json() @@ -324,7 +375,7 @@ export function PolicyGroupManager({ setGroups((prev) => prev.map((g) => - g.id === groupId + g.id === generatePreview.groupId ? { ...g, _count: { tasks: g._count.tasks + data.created } } : g ) @@ -336,10 +387,11 @@ export function PolicyGroupManager({ toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`) onTasksGenerated?.(data.created) } + setGeneratePreview(null) } catch (err: any) { toast.error(err.message || 'Failed to generate tasks') } finally { - setGeneratingFor(null) + setGenerating(false) } } @@ -418,11 +470,11 @@ export function PolicyGroupManager({