Renewal settings, client setup queue, manager setup page, combobox grouping, nav updates

This commit is contained in:
lorentz 2026-04-10 13:50:04 +00:00
parent d109a5f73a
commit 4315f704c7
29 changed files with 2588 additions and 65 deletions

View file

@ -0,0 +1,8 @@
# Purpose
They are used to group together insurance policies with similar renewal dates or common tasks. They can be assigned by the system, manager, or end user.
## Default Renewal Group and Client Renewal Date
The renewal date field for this renewal group will be the `renewalDate` for the client. If the client doesn't have any renewal groups and only has a single policy then the renewal date of that policy will be used. If neither of these scenario's fit then the client does not have a renewal date and one must be assigned by a team member
## New Client
When a client first appears in the system (they would've been synced from AMS360) they will need to be assigned a `renewalDate` and a `claimsAdvocate`. At this time the manager/admin will be able to view any policies (their respective renewal dates) - and see system generated recommendations for Renewal Group and Renewal Date. They can accept the recommendations and assign an advocate, or modify any part. This would include not making a renewal group, manually setting a Renewal Date - or adding any client, or policy specific notes.

View file

@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-avatar": "^1.1.11",
@ -1111,6 +1114,59 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@electric-sql/pglite": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",

View file

@ -19,6 +19,9 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-avatar": "^1.1.11",

View file

@ -126,6 +126,8 @@ model Client {
parentClientId String? @map("parent_client_id")
notes String? @db.Text
customFields Json @default("{}") @map("custom_fields")
renewalDate DateTime? @map("renewal_date")
setupCompletedAt DateTime? @map("setup_completed_at")
lastSyncedAt DateTime? @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -405,6 +407,14 @@ model SyncConfig {
@@map("sync_config")
}
model AppSetting {
key String @id
value String @db.Text
updatedAt DateTime @updatedAt @map("updated_at")
@@map("app_settings")
}
model AuditLog {
id String @id @default(cuid())
userId String? @map("user_id")

View file

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Shapes, FileText, Users, Database, Settings, ClipboardList, History } from 'lucide-react'
import { Shapes, FileText, Users, Database, Settings, ClipboardList, History, CalendarRange } from 'lucide-react'
export default async function AdminPage() {
const session = await getServerSession(authOptions)
@ -75,6 +75,14 @@ export default async function AdminPage() {
color: 'text-teal-600',
bgColor: 'bg-teal-100',
},
{
title: 'Renewal Groups',
description: 'Configure default grouping window and renewal date rules for the client setup wizard',
icon: CalendarRange,
href: '/admin/renewal-settings',
color: 'text-indigo-600',
bgColor: 'bg-indigo-100',
},
]
return (

View file

@ -0,0 +1,47 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { RenewalSettingsForm } from './renewal-settings-form'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
export const dynamic = 'force-dynamic'
export default async function RenewalSettingsPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) redirect('/dashboard')
const settings = await prisma.appSetting.findMany({
where: { key: { in: ['renewal_group_window_days', 'renewal_group_date_rule'] } },
})
const map = Object.fromEntries(settings.map((s: { key: string; value: string }) => [s.key, s.value]))
const windowDays = parseInt(map['renewal_group_window_days'] ?? '90', 10)
const rule = map['renewal_group_date_rule'] ?? 'nearest-to-year-start'
return (
<div className="container mx-auto py-8 max-w-lg space-y-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/admin" className="hover:text-foreground flex items-center gap-1">
<ArrowLeft className="h-3.5 w-3.5" /> Admin
</Link>
<span>/</span>
<span className="text-foreground font-medium">Renewal Groups</span>
</div>
<div>
<h1 className="text-2xl font-bold">Renewal Groups Default Settings</h1>
<p className="text-muted-foreground mt-1 text-sm">
These defaults pre-populate the setup wizard for new clients. They do not change existing
renewal groups.
</p>
</div>
<RenewalSettingsForm windowDays={windowDays} rule={rule} />
</div>
)
}

View file

@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent } from '@/components/ui/card'
import { Save } from 'lucide-react'
interface RenewalSettingsFormProps {
windowDays: number
rule: string
}
export function RenewalSettingsForm({ windowDays: initial, rule: initialRule }: RenewalSettingsFormProps) {
const [windowDays, setWindowDays] = useState(initial)
const [rule, setRule] = useState(initialRule)
const [saving, setSaving] = useState(false)
async function handleSave() {
setSaving(true)
try {
const res = await fetch('/api/admin/renewal-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ windowDays, rule }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Save failed')
toast.success('Settings saved')
} catch (err: any) {
toast.error(err.message || 'Save failed')
} finally {
setSaving(false)
}
}
return (
<Card>
<CardContent className="pt-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="window-days">Default Grouping Window (days)</Label>
<Input
id="window-days"
type="number"
min={1}
max={365}
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Policies with expiration dates within this many days of each other are grouped together.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rule">Default Renewal Date Rule</Label>
<Select value={rule} onValueChange={setRule}>
<SelectTrigger id="rule" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nearest-to-year-start">Nearest to start of year</SelectItem>
<SelectItem value="earliest">Earliest expiration in group</SelectItem>
<SelectItem value="latest">Latest expiration in group</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Determines which expiration date within a group becomes the renewal date (+1 day).
</p>
</div>
<Button onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-4 w-4" />
{saving ? 'Saving…' : 'Save Settings'}
</Button>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,107 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import Link from 'next/link'
import { ArrowLeft, ArrowRight, CalendarRange, Database, Shield } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { SystemSettingsForm } from './system-settings-form'
export const dynamic = 'force-dynamic'
export default async function SystemSettingsPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) redirect('/dashboard')
const [syncConfigs, appSettings] = await Promise.all([
prisma.syncConfig.findMany(),
prisma.appSetting.findMany(),
])
const syncEnabled = syncConfigs.find((c) => c.key === 'sync_enabled')?.value ?? 'false'
const syncSchedule = syncConfigs.find((c) => c.key === 'sync_schedule_cron')?.value ?? '0 2 * * *'
const settingsMap = Object.fromEntries(appSettings.map((s) => [s.key, s.value]))
const windowDays = settingsMap['renewal_group_window_days'] ?? '90'
const dateRule = settingsMap['renewal_group_date_rule'] ?? 'nearest-to-year-start'
const relatedPages = [
{
title: 'Renewal Groups',
description: 'Default grouping window and renewal date rules for the client setup wizard',
href: '/admin/renewal-settings',
icon: CalendarRange,
values: [`${windowDays}-day window`, dateRule],
},
{
title: 'Data Sync',
description: 'AFW synchronisation schedule and manual trigger',
href: '/admin/sync',
icon: Database,
values: [syncEnabled === 'true' ? 'Enabled' : 'Disabled', syncSchedule],
},
]
return (
<div className="container mx-auto py-8 max-w-3xl space-y-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/admin" className="hover:text-foreground flex items-center gap-1">
<ArrowLeft className="h-3.5 w-3.5" /> Admin
</Link>
<span>/</span>
<span className="text-foreground font-medium">System Settings</span>
</div>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Shield className="h-6 w-6" /> System Settings
</h1>
<p className="text-muted-foreground mt-1 text-sm">
System-wide configuration. Changes take effect immediately.
</p>
</div>
{/* Sync config inline editor */}
<SystemSettingsForm syncEnabled={syncEnabled === 'true'} syncSchedule={syncSchedule} />
{/* Links to subsection settings pages */}
<div>
<h2 className="text-base font-semibold mb-3">Feature Settings</h2>
<div className="space-y-3">
{relatedPages.map((page) => {
const Icon = page.icon
return (
<Link key={page.href} href={page.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-sm font-semibold">{page.title}</CardTitle>
<CardDescription className="text-xs mt-0.5">{page.description}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{page.values.map((v, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
{v}
</Badge>
))}
<ArrowRight className="h-4 w-4 text-muted-foreground ml-1" />
</div>
</div>
</CardHeader>
</Card>
</Link>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,86 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { Database, Save } from 'lucide-react'
interface SystemSettingsFormProps {
syncEnabled: boolean
syncSchedule: string
}
export function SystemSettingsForm({ syncEnabled: initialEnabled, syncSchedule: initialSchedule }: SystemSettingsFormProps) {
const [syncEnabled, setSyncEnabled] = useState(initialEnabled)
const [syncSchedule, setSyncSchedule] = useState(initialSchedule)
const [saving, setSaving] = useState(false)
async function handleSave() {
setSaving(true)
try {
const res = await fetch('/api/admin/sync-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncEnabled, syncSchedule }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Save failed')
}
toast.success('Sync settings saved')
} catch (err: any) {
toast.error(err.message || 'Save failed')
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Database className="h-4 w-4" /> Sync Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="sync-enabled" className="font-medium">Enable automatic sync</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Runs the AFW Horizon data sync on the configured schedule.
</p>
</div>
<Switch
id="sync-enabled"
checked={syncEnabled}
onCheckedChange={setSyncEnabled}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sync-schedule">Cron Schedule</Label>
<Input
id="sync-schedule"
value={syncSchedule}
onChange={(e) => setSyncSchedule(e.target.value)}
className="font-mono w-48 text-sm"
placeholder="0 2 * * *"
/>
<p className="text-xs text-muted-foreground">
Default <code className="bg-muted px-1 rounded">0 2 * * *</code> runs daily at 2 AM.
Changes require a systemd timer restart to take effect.
</p>
</div>
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-1.5">
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving…' : 'Save'}
</Button>
</CardContent>
</Card>
)
}

View file

@ -35,6 +35,7 @@ export default async function ClientDetailPage({
parentClient: {
select: { id: true, name: true },
},
// setupCompletedAt and renewalDate are scalar fields, included automatically
subsidiaries: {
select: {
id: true,
@ -160,6 +161,8 @@ export default async function ClientDetailPage({
policyGroups={policyGroups}
allPolicies={allPoliciesData}
canManageGroups={canManageGroups}
canManageSetup={canManageGroups}
setupCompletedAt={client.setupCompletedAt?.toISOString() ?? null}
/>
</div>
)

View file

@ -19,7 +19,7 @@ export default async function ManagerPage() {
}
// Fetch team statistics
const [, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([
const [, activeUsers, totalTasks, completedTasks, overdueTasks, setupQueueCount] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.task.count(),
@ -30,6 +30,15 @@ export default async function ManagerPage() {
status: { not: 'COMPLETED' }
}
}),
prisma.client.count({
where: {
designation: { name: { in: ['Shape', 'Shape 2'] } },
OR: [
{ claimsAdvocateId: null },
{ setupCompletedAt: null },
],
},
}),
])
// Fetch team members with their task counts
@ -99,6 +108,7 @@ export default async function ManagerPage() {
overdueTasks={overdueTasks}
teamMembers={teamMembers}
recentTasks={recentTasks}
setupQueueCount={setupQueueCount}
/>
)
}

View file

@ -0,0 +1,103 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { SetupWizard } from '@/components/renewal-groups/setup-wizard'
export const dynamic = 'force-dynamic'
export default async function SetupWizardPage({
params,
}: {
params: Promise<{ clientId: string }>
}) {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
redirect('/dashboard')
}
const { clientId } = await params
const [client, existingGroups, claimsUsers, settings] = await Promise.all([
prisma.client.findUnique({
where: { id: clientId },
select: {
id: true,
name: true,
notes: true,
claimsAdvocateId: true,
setupCompletedAt: true,
policies: {
where: { expirationDate: { gte: new Date() } },
select: {
id: true,
policyNumber: true,
policyType: true,
carrierName: true,
expirationDate: true,
policyGroupId: true,
},
orderBy: { expirationDate: 'asc' },
},
},
}),
prisma.policyGroup.findMany({
where: { clientId },
select: {
id: true,
name: true,
renewalDate: true,
notes: true,
policies: { select: { id: true } },
},
orderBy: { renewalDate: 'asc' },
}),
prisma.user.findMany({
where: { isActive: true, department: { contains: 'claims', mode: 'insensitive' } },
select: { id: true, displayName: true, email: true },
orderBy: { displayName: 'asc' },
}),
prisma.appSetting.findMany({
where: { key: { in: ['renewal_group_window_days', 'renewal_group_date_rule'] } },
}),
])
if (!client) redirect('/manager/setup')
const settingsMap = Object.fromEntries(settings.map((s) => [s.key, s.value]))
const defaultWindowDays = parseInt(settingsMap['renewal_group_window_days'] ?? '90', 10)
const defaultRule = (settingsMap['renewal_group_date_rule'] ?? 'nearest-to-year-start') as
| 'nearest-to-year-start'
| 'earliest'
| 'latest'
const policiesData = client.policies.map((p) => ({
...p,
expirationDate: p.expirationDate ? p.expirationDate.toISOString() : null,
}))
const groupsData = existingGroups.map((g) => ({
...g,
renewalDate: g.renewalDate.toISOString(),
}))
return (
<div className="container mx-auto py-8">
<SetupWizard
clientId={client.id}
clientName={client.name}
clientNotes={client.notes}
claimsAdvocateId={client.claimsAdvocateId}
setupCompletedAt={client.setupCompletedAt?.toISOString() ?? null}
policies={policiesData}
existingGroups={groupsData}
claimsUsers={claimsUsers}
defaultWindowDays={defaultWindowDays}
defaultRule={defaultRule}
/>
</div>
)
}

View file

@ -0,0 +1,186 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertCircle, ArrowRight, CheckCircle2, Clock } from 'lucide-react'
export const dynamic = 'force-dynamic'
function formatDate(d: Date | null): string {
if (!d) return '—'
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function addDay(d: Date | null): Date | null {
if (!d) return null
const r = new Date(d)
r.setDate(r.getDate() + 1)
return r
}
export default async function SetupQueuePage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
redirect('/dashboard')
}
const clients = await prisma.client.findMany({
where: {
designation: { name: { in: ['Shape', 'Shape 2'] } },
OR: [
{ claimsAdvocateId: null },
{ setupCompletedAt: null },
],
},
select: {
id: true,
name: true,
createdAt: true,
claimsAdvocateId: true,
setupCompletedAt: true,
policies: {
select: { expirationDate: true },
},
_count: { select: { policies: true } },
},
orderBy: { createdAt: 'asc' },
})
const now = new Date()
const rows = clients.map((c) => {
const expirations = c.policies.map((p) => p.expirationDate)
const earliest = expirations.length
? addDay(expirations.reduce((a, b) => (a < b ? a : b)))
: null
const latest = expirations.length
? addDay(expirations.reduce((a, b) => (a > b ? a : b)))
: null
const daysInQueue = Math.floor(
(now.getTime() - c.createdAt.getTime()) / (1000 * 60 * 60 * 24)
)
return {
id: c.id,
name: c.name,
policyCount: c._count.policies,
earliestRenewal: earliest,
latestRenewal: latest,
daysInQueue,
missingAdvocate: !c.claimsAdvocateId,
missingSetup: !c.setupCompletedAt,
}
})
return (
<div className="container mx-auto py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">New Client Setup</h1>
<p className="text-muted-foreground mt-1">
Clients that need a claims advocate or renewal group configuration.
</p>
</div>
<Badge variant="secondary" className="text-base px-3 py-1">
{rows.length} pending
</Badge>
</div>
{rows.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<p className="text-xl font-semibold">All clients are configured</p>
<p className="text-muted-foreground">No clients are awaiting setup.</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Pending Setup</CardTitle>
<CardDescription>
Click a client name to open the setup wizard.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Client</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground">Policies</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Earliest Renewal</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Latest Renewal</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Issues</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">In Queue</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y">
{rows.map((row) => {
const urgency =
row.daysInQueue > 10
? 'text-red-600 font-semibold'
: row.daysInQueue > 5
? 'text-amber-600 font-semibold'
: 'text-muted-foreground'
return (
<tr key={row.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">
<Link
href={`/manager/setup/${row.id}`}
className="hover:underline text-primary"
>
{row.name}
</Link>
</td>
<td className="px-4 py-3 text-center">{row.policyCount}</td>
<td className="px-4 py-3 text-muted-foreground" suppressHydrationWarning>
{formatDate(row.earliestRenewal)}
</td>
<td className="px-4 py-3 text-muted-foreground" suppressHydrationWarning>
{formatDate(row.latestRenewal)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{row.missingAdvocate && (
<Badge variant="destructive" className="text-xs gap-1">
<AlertCircle className="h-3 w-3" /> No Advocate
</Badge>
)}
{row.missingSetup && (
<Badge variant="outline" className="text-xs gap-1">
<Clock className="h-3 w-3" /> No Setup
</Badge>
)}
</div>
</td>
<td className={`px-4 py-3 ${urgency}`} suppressHydrationWarning>
{row.daysInQueue}d
</td>
<td className="px-4 py-3">
<Link
href={`/manager/setup/${row.id}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Configure <ArrowRight className="h-3 w-3" />
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
const WINDOW_KEY = 'renewal_group_window_days'
const RULE_KEY = 'renewal_group_date_rule'
const VALID_RULES = ['nearest-to-year-start', 'earliest', 'latest']
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await prisma.appSetting.findMany({
where: { key: { in: [WINDOW_KEY, RULE_KEY] } },
})
const map = Object.fromEntries(settings.map((s) => [s.key, s.value]))
return NextResponse.json({
windowDays: parseInt(map[WINDOW_KEY] ?? '90', 10),
rule: map[RULE_KEY] ?? 'nearest-to-year-start',
})
} catch (error) {
console.error('Renewal settings GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { windowDays, rule } = body
if (typeof windowDays !== 'number' || windowDays < 1 || windowDays > 365) {
return NextResponse.json({ error: 'windowDays must be between 1 and 365' }, { status: 400 })
}
if (!VALID_RULES.includes(rule)) {
return NextResponse.json({ error: 'Invalid rule value' }, { status: 400 })
}
await prisma.$transaction([
prisma.appSetting.upsert({
where: { key: WINDOW_KEY },
update: { value: String(windowDays) },
create: { key: WINDOW_KEY, value: String(windowDays) },
}),
prisma.appSetting.upsert({
where: { key: RULE_KEY },
update: { value: rule },
create: { key: RULE_KEY, value: rule },
}),
])
return NextResponse.json({ windowDays, rule })
} catch (error) {
console.error('Renewal settings PUT error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const configs = await prisma.syncConfig.findMany()
const map = Object.fromEntries(configs.map((c) => [c.key, c.value]))
return NextResponse.json({
syncEnabled: map['sync_enabled'] === 'true',
syncSchedule: map['sync_schedule_cron'] ?? '0 2 * * *',
})
} catch (error) {
console.error('sync-config GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { syncEnabled, syncSchedule } = await request.json()
if (typeof syncEnabled !== 'boolean') {
return NextResponse.json({ error: 'syncEnabled must be a boolean' }, { status: 400 })
}
if (typeof syncSchedule !== 'string' || syncSchedule.trim().split(/\s+/).length !== 5) {
return NextResponse.json({ error: 'syncSchedule must be a 5-part cron expression' }, { status: 400 })
}
await prisma.$transaction([
prisma.syncConfig.upsert({
where: { key: 'sync_enabled' },
update: { value: String(syncEnabled) },
create: { key: 'sync_enabled', value: String(syncEnabled) },
}),
prisma.syncConfig.upsert({
where: { key: 'sync_schedule_cron' },
update: { value: syncSchedule.trim() },
create: { key: 'sync_schedule_cron', value: syncSchedule.trim() },
}),
])
return NextResponse.json({ syncEnabled, syncSchedule: syncSchedule.trim() })
} catch (error) {
console.error('sync-config PUT error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
interface GroupPayload {
id?: string
name: string
renewalDate: string
notes?: string | null
policyIds: string[]
isDefault?: boolean
}
interface SetupPayload {
groups: GroupPayload[]
claimsAdvocateId: string
clientNotes?: string | null
isDraft?: boolean
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return handleSave(request, params, false)
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return handleSave(request, params, true)
}
async function handleSave(
request: NextRequest,
paramsPromise: Promise<{ id: string }>,
isDraftOverride: boolean
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id: clientId } = await paramsPromise
const body: SetupPayload = await request.json()
const isDraft = isDraftOverride || body.isDraft === true
const defaultGroup = body.groups.find((g) => g.isDefault) ?? body.groups[0]
const defaultRenewalDate = defaultGroup
? new Date(defaultGroup.renewalDate)
: null
const existingGroups = await prisma.policyGroup.findMany({
where: { clientId },
select: { id: true },
})
const existingGroupIds = new Set(existingGroups.map((g) => g.id))
const incomingGroupIds = new Set(
body.groups.filter((g) => g.id).map((g) => g.id as string)
)
const toDelete = [...existingGroupIds].filter((id) => !incomingGroupIds.has(id))
await prisma.$transaction(async (tx) => {
if (toDelete.length > 0) {
await tx.policyGroup.deleteMany({
where: { id: { in: toDelete }, clientId },
})
}
for (const group of body.groups) {
const renewalDate = new Date(group.renewalDate)
let groupId: string
if (group.id && existingGroupIds.has(group.id)) {
await tx.policyGroup.update({
where: { id: group.id },
data: {
name: group.name,
renewalDate,
notes: group.notes ?? null,
},
})
groupId = group.id
} else {
const created = await tx.policyGroup.create({
data: {
clientId,
name: group.name,
renewalDate,
notes: group.notes ?? null,
createdBy: (session.user as any).id,
},
})
groupId = created.id
}
if (group.policyIds.length > 0) {
await tx.policy.updateMany({
where: { id: { in: group.policyIds }, clientId },
data: { policyGroupId: groupId },
})
}
}
await tx.client.update({
where: { id: clientId },
data: {
claimsAdvocateId: body.claimsAdvocateId || undefined,
notes: body.clientNotes ?? undefined,
renewalDate: defaultRenewalDate,
setupCompletedAt: isDraft ? undefined : new Date(),
},
})
})
const updated = await prisma.client.findUnique({
where: { id: clientId },
select: { id: true, name: true, renewalDate: true, setupCompletedAt: true, claimsAdvocateId: true },
})
return NextResponse.json({ client: updated })
} catch (error) {
console.error('Setup save error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const countOnly = searchParams.get('count') === 'true'
const where = {
designation: { name: { in: ['Shape', 'Shape 2'] } },
OR: [
{ claimsAdvocateId: null },
{ setupCompletedAt: null },
],
}
if (countOnly) {
const count = await prisma.client.count({ where })
return NextResponse.json({ count })
}
const clients = await prisma.client.findMany({
where,
select: {
id: true,
name: true,
createdAt: true,
claimsAdvocateId: true,
setupCompletedAt: true,
policies: {
select: {
id: true,
expirationDate: true,
},
},
_count: {
select: { policies: true },
},
},
orderBy: { createdAt: 'asc' },
})
const now = new Date()
const result = clients.map((c) => {
const expirations = c.policies
.map((p) => p.expirationDate)
.filter(Boolean) as Date[]
const earliest = expirations.length
? expirations.reduce((a, b) => (a < b ? a : b))
: null
const latest = expirations.length
? expirations.reduce((a, b) => (a > b ? a : b))
: null
const daysInQueue = Math.floor(
(now.getTime() - c.createdAt.getTime()) / (1000 * 60 * 60 * 24)
)
return {
id: c.id,
name: c.name,
policyCount: c._count.policies,
earliestExpiry: earliest?.toISOString() ?? null,
latestExpiry: latest?.toISOString() ?? null,
daysInQueue,
missingAdvocate: !c.claimsAdvocateId,
missingSetup: !c.setupCompletedAt,
}
})
return NextResponse.json({ clients: result })
} catch (error) {
console.error('Setup queue API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -20,8 +20,8 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight } from 'lucide-react'
import { Combobox } from '@/components/ui/combobox'
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight, Settings2 } from 'lucide-react'
import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
import { formatDate, formatRenewalDate } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
@ -31,6 +31,7 @@ interface SimpleUser {
id: string
displayName: string | null
email: string
department?: string | null
}
interface ClientDetailProps {
@ -39,9 +40,11 @@ interface ClientDetailProps {
policyGroups?: any[]
allPolicies?: any[]
canManageGroups?: boolean
canManageSetup?: boolean
setupCompletedAt?: string | null
}
export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false }: ClientDetailProps) {
export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false, canManageSetup = false, setupCompletedAt }: ClientDetailProps) {
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
@ -331,9 +334,21 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
</div>
</div>
{/* Parent / Child badges — right-aligned */}
{(isParent || isChild) && (
<div className="flex flex-wrap gap-2 shrink-0 pt-1">
{/* Right-side header actions */}
<div className="flex flex-wrap gap-2 shrink-0 pt-1 items-start">
{canManageSetup && (
<Link
href={`/manager/setup/${client.id}`}
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
setupCompletedAt
? 'border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary'
: 'border-orange-400 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:bg-orange-950/20 dark:text-orange-400'
}`}
>
<Settings2 className="h-3.5 w-3.5" />
{setupCompletedAt ? 'Edit Setup' : 'Complete Setup'}
</Link>
)}
{isParent && (
<button
onClick={() => setSubsidiariesModalOpen(true)}
@ -353,7 +368,6 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
</Link>
)}
</div>
)}
</div>
{/* Subsidiaries modal */}
@ -956,9 +970,21 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
<div className="flex gap-2 pt-1">
<Combobox
className="flex-1"
options={allUsers
.filter((u) => !members.some((m: any) => m.userId === u.id))
.map((u) => ({ value: u.id, label: u.displayName || u.email }))}
groups={(() => {
const available = allUsers.filter((u) => !members.some((m: any) => m.userId === u.id))
const toOption = (u: SimpleUser) => ({ value: u.id, label: u.displayName || u.email })
const claims = available.filter((u) => u.department?.toLowerCase().includes('claims'))
const others = available.filter((u) => !u.department?.toLowerCase().includes('claims'))
const deptMap = new Map<string, SimpleUser[]>()
others.forEach((u) => {
const dept = u.department || 'Other'
deptMap.set(dept, [...(deptMap.get(dept) ?? []), u])
})
const groups: ComboboxGroup[] = []
if (claims.length) groups.push({ label: 'Claims', options: claims.map(toOption), defaultExpanded: true })
deptMap.forEach((users, dept) => groups.push({ label: dept, options: users.map(toOption), defaultExpanded: false }))
return groups
})()}
value={addMemberId}
onChange={setAddMemberId}
placeholder="Add member..."

View file

@ -13,6 +13,7 @@ import {
} from '@/components/ui/select'
import { ClientCard } from './client-card'
import { ClientTable } from './client-table'
import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
import { Search, Filter, LayoutGrid, List, X, EyeOff, Eye } from 'lucide-react'
type ViewMode = 'cards' | 'table'
@ -152,17 +153,26 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
))}
</SelectContent>
</Select>
<Select value={teamMemberFilter || '_all'} onValueChange={handleFilterChange(setTeamMemberFilter)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Team Members" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Team Members</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
className="w-[180px]"
placeholder="All Team Members"
value={teamMemberFilter}
onChange={(v) => { setTeamMemberFilter(v); setPage(1) }}
groups={(() => {
const toOption = (u: SimpleUser) => ({ value: u.id, label: u.displayName || u.email })
const claims = users.filter((u) => u.department?.toLowerCase().includes('claims'))
const others = users.filter((u) => !u.department?.toLowerCase().includes('claims'))
const deptMap = new Map<string, SimpleUser[]>()
others.forEach((u) => {
const dept = u.department || 'Other'
deptMap.set(dept, [...(deptMap.get(dept) ?? []), u])
})
const groups: ComboboxGroup[] = []
if (claims.length) groups.push({ label: 'Claims', options: claims.map(toOption), defaultExpanded: false })
deptMap.forEach((us, dept) => groups.push({ label: dept, options: us.map(toOption), defaultExpanded: false }))
return groups
})()}
/>
<Button
variant={showNoPolicies ? 'secondary' : 'outline'}
size="sm"

View file

@ -3,6 +3,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@ -21,6 +22,15 @@ export function NavBar() {
const userRoles = (session?.user as any)?.roles || []
const isAdmin = userRoles.includes('Admin')
const isManager = userRoles.includes('Manager') || isAdmin
const [setupCount, setSetupCount] = useState(0)
useEffect(() => {
if (!isManager) return
fetch('/api/clients/setup-queue?count=true')
.then((r) => r.json())
.then((d) => setSetupCount(d.count ?? 0))
.catch(() => {})
}, [isManager])
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
@ -29,7 +39,7 @@ export function NavBar() {
]
if (isManager) {
navItems.push({ href: '/manager', label: 'Manager', icon: Users })
navItems.push({ href: '/manager', label: 'Manager', icon: Users, badge: setupCount > 0 ? setupCount : undefined } as any)
navItems.push({ href: '/tasks/assign', label: 'Assign Tasks', icon: UserPlus })
}
@ -53,11 +63,12 @@ export function NavBar() {
{navItems.map((item) => {
const Icon = item.icon
const isActive = pathname.startsWith(item.href)
const badge = (item as any).badge
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
className={`relative flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@ -65,6 +76,11 @@ export function NavBar() {
>
<Icon className="h-4 w-4" />
{item.label}
{badge && (
<span className="ml-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{badge}
</span>
)}
</Link>
)
})}

View file

@ -4,7 +4,8 @@ import { useState, useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock } from 'lucide-react'
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock, UserCog } from 'lucide-react'
import Link from 'next/link'
import { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
import { formatDate } from '@/lib/utils'
@ -16,6 +17,7 @@ interface ManagerPageClientProps {
overdueTasks: number
teamMembers: any[]
recentTasks: any[]
setupQueueCount: number
}
export function ManagerPageClient({
@ -25,6 +27,7 @@ export function ManagerPageClient({
overdueTasks,
teamMembers,
recentTasks,
setupQueueCount,
}: ManagerPageClientProps) {
const [claimsOnly, setClaimsOnly] = useState(true)
@ -95,6 +98,22 @@ export function ManagerPageClient({
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Link href="/manager/setup">
<Card className={`border-l-4 hover:shadow-lg transition-shadow cursor-pointer ${
setupQueueCount > 0 ? 'border-l-orange-500' : 'border-l-green-500'
}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">New Client Setup</CardTitle>
<UserCog className={`h-5 w-5 ${setupQueueCount > 0 ? 'text-orange-500' : 'text-green-500'}`} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{setupQueueCount}</div>
<p className="text-xs text-muted-foreground mt-1">
{setupQueueCount === 0 ? 'All configured' : 'Awaiting setup'}
</p>
</CardContent>
</Card>
</Link>
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Team Members</CardTitle>

View file

@ -0,0 +1,196 @@
'use client'
import { useDroppable } from '@dnd-kit/core'
import { Trash2, Star, StarOff } from 'lucide-react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { PolicyChip, PolicyChipData } from './policy-chip'
import { DateRule } from '@/lib/renewal-group-recommendations'
export interface GroupState {
id: string
dbId?: string
name: string
policies: PolicyChipData[]
rule: DateRule | 'manual'
renewalDate: string
notes: string
isDefault: boolean
}
interface GroupCardProps {
group: GroupState
isOnlyGroup: boolean
onUpdate: (updated: Partial<GroupState>) => void
onDelete: () => void
onSetDefault: () => void
onSplitPolicy: (policyId: string) => void
}
function computeRenewalDate(policies: PolicyChipData[], rule: DateRule | 'manual'): string {
if (rule === 'manual') return ''
const dates = policies
.filter((p) => p.expirationDate)
.map((p) => new Date(p.expirationDate as string))
if (dates.length === 0) return ''
let picked: Date
if (rule === 'earliest') {
picked = dates.reduce((a, b) => (a < b ? a : b))
} else if (rule === 'latest') {
picked = dates.reduce((a, b) => (a > b ? a : b))
} else {
const dayOfYear = (d: Date) => {
const start = new Date(d.getFullYear(), 0, 0)
return Math.floor((d.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
}
picked = dates.reduce((a, b) => (dayOfYear(a) <= dayOfYear(b) ? a : b))
}
const result = new Date(picked)
result.setDate(result.getDate() + 1)
return result.toISOString().split('T')[0]
}
export function GroupCard({
group,
isOnlyGroup,
onUpdate,
onDelete,
onSetDefault,
onSplitPolicy,
}: GroupCardProps) {
const { setNodeRef, isOver } = useDroppable({ id: group.id })
const handleRuleChange = (newRule: DateRule | 'manual') => {
if (newRule !== 'manual') {
const computed = computeRenewalDate(group.policies, newRule)
onUpdate({ rule: newRule, renewalDate: computed })
} else {
onUpdate({ rule: newRule })
}
}
const displayRenewalDate = group.renewalDate
? new Date(group.renewalDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: '—'
return (
<Card
ref={setNodeRef}
className={`transition-all ${isOver ? 'ring-2 ring-primary border-primary bg-primary/5' : ''} ${
group.isDefault ? 'border-primary/60' : ''
}`}
>
<CardHeader className="pb-2 pt-3 px-3">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0 space-y-2">
<Input
value={group.name}
onChange={(e) => onUpdate({ name: e.target.value })}
className="font-semibold h-8 text-sm"
placeholder="Group name"
/>
<div className="flex items-center gap-2 flex-wrap">
<Select value={group.rule} onValueChange={(v) => handleRuleChange(v as DateRule | 'manual')}>
<SelectTrigger className="h-7 text-xs w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nearest-to-year-start">Nearest to year start</SelectItem>
<SelectItem value="earliest">Earliest in group</SelectItem>
<SelectItem value="latest">Latest in group</SelectItem>
<SelectItem value="manual">Manual date</SelectItem>
</SelectContent>
</Select>
{group.rule === 'manual' ? (
<Input
type="date"
value={group.renewalDate}
onChange={(e) => onUpdate({ renewalDate: e.target.value })}
className="h-7 text-xs w-36"
/>
) : (
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
Renewal: <span className="font-medium text-foreground">{displayRenewalDate}</span>
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{!isOnlyGroup && (
<button
type="button"
onClick={onSetDefault}
title={group.isDefault ? 'Default group' : 'Set as default'}
className={`p-1 rounded transition-colors ${
group.isDefault
? 'text-yellow-500'
: 'text-muted-foreground hover:text-yellow-500'
}`}
>
{group.isDefault ? (
<Star className="h-4 w-4 fill-current" />
) : (
<StarOff className="h-4 w-4" />
)}
</button>
)}
<button
type="button"
onClick={onDelete}
title="Delete group"
className="p-1 rounded text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 space-y-3">
<div className="space-y-1.5 min-h-[40px]">
{group.policies.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed rounded-md">
Drop policies here
</p>
) : (
group.policies.map((policy) => (
<div key={policy.id} className="group/chip relative">
<PolicyChip policy={policy} />
{group.policies.length > 1 && (
<button
type="button"
onClick={() => onSplitPolicy(policy.id)}
title="Move to new group"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/chip:opacity-100 text-[10px] text-muted-foreground hover:text-foreground bg-background border rounded px-1 py-0.5 transition-opacity"
>
Split
</button>
)}
</div>
))
)}
</div>
<div>
<Label className="text-xs text-muted-foreground">Group notes (optional)</Label>
<Textarea
value={group.notes}
onChange={(e) => onUpdate({ notes: e.target.value })}
className="text-xs mt-1 min-h-[52px] resize-none"
placeholder="Any notes for this group..."
/>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,74 @@
'use client'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { AlertTriangle, GripVertical } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
export interface PolicyChipData {
id: string
policyNumber: string | null
policyType: string | null
carrierName: string | null
expirationDate: string | null
groupId: string
}
interface PolicyChipProps {
policy: PolicyChipData
disabled?: boolean
}
export function PolicyChip({ policy, disabled }: PolicyChipProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: policy.id,
data: { policy },
disabled: disabled || !policy.expirationDate,
})
const style = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : 1,
cursor: !policy.expirationDate ? 'not-allowed' : isDragging ? 'grabbing' : 'grab',
}
const expDisplay = policy.expirationDate
? new Date(policy.expirationDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`flex items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm select-none touch-none
${!policy.expirationDate ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : 'hover:border-primary/50'}
${isDragging ? 'ring-2 ring-primary shadow-lg' : ''}
`}
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-medium truncate">
{policy.policyNumber || 'No number'}{' '}
{policy.policyType && (
<span className="text-muted-foreground font-normal">· {policy.policyType}</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
{policy.carrierName || 'Unknown carrier'}
{expDisplay && <span suppressHydrationWarning> · Exp {expDisplay}</span>}
</div>
</div>
{!policy.expirationDate && (
<Badge variant="outline" className="text-amber-600 border-amber-400 shrink-0 gap-1 text-[10px]">
<AlertTriangle className="h-2.5 w-2.5" /> No Expiry
</Badge>
)}
</div>
)
}

View file

@ -0,0 +1,601 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { toast } from 'sonner'
import { Plus, RefreshCw, Save, CheckCircle, ArrowLeft, Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import Link from 'next/link'
import { recommendGroups, DateRule } from '@/lib/renewal-group-recommendations'
import { GroupCard, GroupState } from './group-card'
import { PolicyChip, PolicyChipData } from './policy-chip'
interface WizardPolicy {
id: string
policyNumber: string | null
policyType: string | null
carrierName: string | null
expirationDate: string | null
policyGroupId: string | null
}
interface ExistingGroup {
id: string
name: string
renewalDate: string
notes: string | null
policies: { id: string }[]
}
interface ClaimsUser {
id: string
displayName: string | null
email: string
}
interface SetupWizardProps {
clientId: string
clientName: string
clientNotes: string | null
claimsAdvocateId: string | null
setupCompletedAt: string | null
policies: WizardPolicy[]
existingGroups: ExistingGroup[]
claimsUsers: ClaimsUser[]
defaultWindowDays: number
defaultRule: DateRule
}
let groupSeq = 0
function newGroupId() {
return `new-${++groupSeq}`
}
function buildPolicyChip(p: WizardPolicy, groupId: string): PolicyChipData {
return {
id: p.id,
policyNumber: p.policyNumber,
policyType: p.policyType,
carrierName: p.carrierName,
expirationDate: p.expirationDate,
groupId,
}
}
function computeRenewalDate(policies: PolicyChipData[], rule: DateRule): string {
const dates = policies
.filter((p) => p.expirationDate)
.map((p) => new Date(p.expirationDate as string))
if (dates.length === 0) return ''
const dayOfYear = (d: Date) => {
const start = new Date(d.getFullYear(), 0, 0)
return Math.floor((d.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
}
let picked: Date
if (rule === 'earliest') picked = dates.reduce((a, b) => (a < b ? a : b))
else if (rule === 'latest') picked = dates.reduce((a, b) => (a > b ? a : b))
else picked = dates.reduce((a, b) => (dayOfYear(a) <= dayOfYear(b) ? a : b))
const result = new Date(picked)
result.setDate(result.getDate() + 1)
return result.toISOString().split('T')[0]
}
function runEngine(
policies: WizardPolicy[],
windowDays: number,
rule: DateRule
): GroupState[] {
const inputs = policies
.filter((p) => p.expirationDate)
.map((p) => ({ policyId: p.id, expirationDate: new Date(p.expirationDate as string) }))
const result = recommendGroups(inputs, { windowDays, rule })
const groups: GroupState[] = result.groups.map((g, i) => {
const gid = newGroupId()
return {
id: gid,
name: `Renewal Group ${i + 1}`,
policies: g.policies.map((pi) => {
const pol = policies.find((p) => p.id === pi.policyId)!
return buildPolicyChip(pol, gid)
}),
rule,
renewalDate: g.proposedRenewalDate.toISOString().split('T')[0],
notes: '',
isDefault: i === 0,
}
})
return groups
}
const UNASSIGNED_ID = '__unassigned__'
export function SetupWizard({
clientId,
clientName,
clientNotes: initialClientNotes,
claimsAdvocateId: initialAdvocateId,
setupCompletedAt,
policies,
existingGroups,
claimsUsers,
defaultWindowDays,
defaultRule,
}: SetupWizardProps) {
const router = useRouter()
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const isReopen = !!setupCompletedAt && existingGroups.length > 0
const initializedRef = useRef(false)
const buildInitialGroups = useCallback((): GroupState[] => {
if (existingGroups.length > 0) {
return existingGroups.map((eg, i) => {
const gid = newGroupId()
return {
id: gid,
dbId: eg.id,
name: eg.name,
policies: eg.policies.map((pi) => {
const pol = policies.find((p) => p.id === pi.id)
if (!pol) return null
return buildPolicyChip(pol, gid)
}).filter(Boolean) as PolicyChipData[],
rule: 'nearest-to-year-start' as DateRule,
renewalDate: new Date(eg.renewalDate).toISOString().split('T')[0],
notes: eg.notes || '',
isDefault: i === 0,
}
})
}
return runEngine(policies, defaultWindowDays, defaultRule)
}, [existingGroups, policies, defaultWindowDays, defaultRule])
const [groups, setGroups] = useState<GroupState[]>(() => buildInitialGroups())
const [windowDays, setWindowDays] = useState(defaultWindowDays)
const [rule, setRule] = useState<DateRule>(defaultRule)
const [advocateId, setAdvocateId] = useState(initialAdvocateId || '')
const [clientNotes, setClientNotes] = useState(initialClientNotes || '')
const [wizardDirty, setWizardDirty] = useState(false)
const [activePolicy, setActivePolicy] = useState<PolicyChipData | null>(null)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true
return
}
setWizardDirty(true)
}, [groups, advocateId, clientNotes])
const allAssignedPolicyIds = new Set(groups.flatMap((g) => g.policies.map((p) => p.id)))
const unassignedPolicies = policies
.filter((p) => !allAssignedPolicyIds.has(p.id))
.map((p) => buildPolicyChip(p, UNASSIGNED_ID))
const { setNodeRef: unassignedRef, isOver: isOverUnassigned } = useDroppable({ id: UNASSIGNED_ID } as any)
function handleRerun() {
if (wizardDirty) {
setRerunDialogOpen(true)
} else {
doRerun()
}
}
function doRerun() {
const newGroups = runEngine(policies, windowDays, rule)
setGroups(newGroups)
setWizardDirty(false)
setRerunDialogOpen(false)
}
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as { policy: PolicyChipData }
setActivePolicy(data?.policy ?? null)
}
function handleDragEnd(event: DragEndEvent) {
setActivePolicy(null)
const { active, over } = event
if (!over) return
const draggedPolicyId = active.id as string
const targetGroupId = over.id as string
const sourceGroupIdx = groups.findIndex((g) => g.policies.some((p) => p.id === draggedPolicyId))
const isFromUnassigned = sourceGroupIdx === -1
if (targetGroupId === UNASSIGNED_ID) {
if (isFromUnassigned) return
setGroups((prev) => {
const updated = prev.map((g, i) => {
if (i !== sourceGroupIdx) return g
return { ...g, policies: g.policies.filter((p) => p.id !== draggedPolicyId) }
})
return updated
})
setWizardDirty(true)
return
}
const targetGroupIdx = groups.findIndex((g) => g.id === targetGroupId)
if (targetGroupIdx === -1) return
if (!isFromUnassigned && sourceGroupIdx === targetGroupIdx) return
setGroups((prev) => {
const next = prev.map((g) => ({ ...g, policies: [...g.policies] }))
let draggedPolicy: PolicyChipData | undefined
if (isFromUnassigned) {
const pol = policies.find((p) => p.id === draggedPolicyId)!
draggedPolicy = buildPolicyChip(pol, targetGroupId)
} else {
draggedPolicy = next[sourceGroupIdx].policies.find((p) => p.id === draggedPolicyId)
next[sourceGroupIdx].policies = next[sourceGroupIdx].policies.filter(
(p) => p.id !== draggedPolicyId
)
}
if (draggedPolicy) {
draggedPolicy = { ...draggedPolicy, groupId: targetGroupId }
next[targetGroupIdx].policies.push(draggedPolicy)
}
return next.map((g) => {
if (g.rule !== 'manual') {
return { ...g, renewalDate: computeRenewalDate(g.policies, g.rule as DateRule) }
}
return g
})
})
setWizardDirty(true)
}
function addGroup() {
const gid = newGroupId()
setGroups((prev) => [
...prev,
{
id: gid,
name: `Group ${prev.length + 1}`,
policies: [],
rule: rule,
renewalDate: '',
notes: '',
isDefault: false,
},
])
setWizardDirty(true)
}
function deleteGroup(groupId: string) {
setGroups((prev) => {
const filtered = prev.filter((g) => g.id !== groupId)
if (filtered.length > 0 && !filtered.some((g) => g.isDefault)) {
filtered[0].isDefault = true
}
return filtered
})
setWizardDirty(true)
}
function updateGroup(groupId: string, updates: Partial<GroupState>) {
setGroups((prev) =>
prev.map((g) => (g.id === groupId ? { ...g, ...updates } : g))
)
}
function setDefault(groupId: string) {
setGroups((prev) =>
prev.map((g) => ({ ...g, isDefault: g.id === groupId }))
)
setWizardDirty(true)
}
function splitPolicy(sourceGroupId: string, policyId: string) {
const gid = newGroupId()
setGroups((prev) => {
const sourceIdx = prev.findIndex((g) => g.id === sourceGroupId)
if (sourceIdx === -1) return prev
const policy = prev[sourceIdx].policies.find((p) => p.id === policyId)
if (!policy) return prev
const next = prev.map((g) => ({ ...g, policies: [...g.policies] }))
next[sourceIdx].policies = next[sourceIdx].policies.filter((p) => p.id !== policyId)
const newPol = { ...policy, groupId: gid }
next.push({
id: gid,
name: `Group ${next.length + 1}`,
policies: [newPol],
rule,
renewalDate: policy.expirationDate
? new Date(new Date(policy.expirationDate).getTime() + 86400000).toISOString().split('T')[0]
: '',
notes: '',
isDefault: false,
})
return next.map((g) => {
if (g.rule !== 'manual') {
return { ...g, renewalDate: computeRenewalDate(g.policies, g.rule as DateRule) }
}
return g
})
})
setWizardDirty(true)
}
async function handleSave(draft: boolean) {
if (!draft && !advocateId) {
toast.error('Please select a Claims Advocate before saving.')
return
}
setSaving(true)
try {
const payload = {
groups: groups.map((g) => ({
id: g.dbId,
name: g.name,
renewalDate: g.renewalDate,
notes: g.notes || null,
policyIds: g.policies.map((p) => p.id),
isDefault: g.isDefault,
})),
claimsAdvocateId: advocateId,
clientNotes: clientNotes || null,
isDraft: draft,
}
const method = draft ? 'PUT' : 'POST'
const res = await fetch(`/api/clients/${clientId}/setup`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Save failed')
if (draft) {
toast.success('Draft saved')
setWizardDirty(false)
} else {
toast.success('Setup complete!')
router.push(`/clients/${clientId}`)
}
} catch (err: any) {
toast.error(err.message || 'Save failed')
} finally {
setSaving(false)
}
}
const canFinish = !!advocateId && groups.length > 0
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/manager/setup" className="hover:text-foreground flex items-center gap-1">
<ArrowLeft className="h-3.5 w-3.5" /> New Client Setup
</Link>
<span>/</span>
<span className="text-foreground font-medium">{clientName}</span>
</div>
{/* Reopen banner */}
{isReopen && (
<div className="flex items-start gap-2 rounded-md border border-blue-300 bg-blue-50 dark:bg-blue-950/20 px-4 py-3 text-sm text-blue-800 dark:text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Re-configuring an existing setup. Existing groups are pre-loaded. Changes will update the
database when you Save &amp; Complete.
</span>
</div>
)}
{/* Toolbar */}
<div className="flex flex-wrap items-end gap-4 p-4 rounded-lg border bg-muted/30">
<div className="space-y-1">
<Label className="text-xs">Grouping Window (days)</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
className="w-20 h-8 text-sm"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleRerun}
className="gap-1.5 h-8"
>
<RefreshCw className="h-3.5 w-3.5" /> Re-run Recommendations
</Button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Default Date Rule</Label>
<Select value={rule} onValueChange={(v) => setRule(v as DateRule)}>
<SelectTrigger className="h-8 text-sm w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nearest-to-year-start">Nearest to year start</SelectItem>
<SelectItem value="earliest">Earliest in group</SelectItem>
<SelectItem value="latest">Latest in group</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="button" size="sm" variant="outline" onClick={addGroup} className="gap-1.5 h-8 ml-auto">
<Plus className="h-3.5 w-3.5" /> Add Group
</Button>
</div>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Groups grid */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map((group) => (
<GroupCard
key={group.id}
group={group}
isOnlyGroup={groups.length === 1}
onUpdate={(updates) => updateGroup(group.id, updates)}
onDelete={() => deleteGroup(group.id)}
onSetDefault={() => setDefault(group.id)}
onSplitPolicy={(policyId) => splitPolicy(group.id, policyId)}
/>
))}
</div>
{/* Unassigned tray */}
{(unassignedPolicies.length > 0 || groups.length > 0) && (
<div
ref={unassignedRef as any}
className={`rounded-lg border-2 border-dashed p-4 transition-colors ${
isOverUnassigned ? 'border-primary bg-primary/5' : 'border-muted-foreground/30'
}`}
>
<p className="text-sm font-medium mb-2 text-muted-foreground">
Unassigned Policies ({unassignedPolicies.length})
</p>
{unassignedPolicies.length === 0 ? (
<p className="text-xs text-muted-foreground italic">All policies are assigned to groups.</p>
) : (
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{unassignedPolicies.map((p) => (
<PolicyChip key={p.id} policy={p} />
))}
</div>
)}
</div>
)}
<DragOverlay>
{activePolicy && <PolicyChip policy={activePolicy} />}
</DragOverlay>
</DndContext>
{/* Advocate + notes */}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label>
Claims Advocate <span className="text-destructive">*</span>
</Label>
<Select value={advocateId} onValueChange={setAdvocateId}>
<SelectTrigger>
<SelectValue placeholder="Select advocate..." />
</SelectTrigger>
<SelectContent>
{claimsUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
{!advocateId && (
<p className="text-xs text-muted-foreground">Required before Save &amp; Complete.</p>
)}
</div>
<div className="space-y-2">
<Label>Client Notes (optional)</Label>
<Textarea
value={clientNotes}
onChange={(e) => setClientNotes(e.target.value)}
className="resize-none min-h-[80px]"
placeholder="Any notes for this client..."
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-2">
{groups.length > 0 && (
<p className="text-sm text-muted-foreground">
{groups.length} group{groups.length !== 1 ? 's' : ''} ·{' '}
{groups.reduce((s, g) => s + g.policies.length, 0)} assigned ·{' '}
{unassignedPolicies.length} unassigned
</p>
)}
{wizardDirty && (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-400">
Unsaved changes
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => handleSave(true)}
className="gap-1.5"
>
<Save className="h-4 w-4" /> Save Draft
</Button>
<Button
type="button"
disabled={!canFinish || saving}
onClick={() => handleSave(false)}
className="gap-1.5"
>
<CheckCircle className="h-4 w-4" /> Save &amp; Complete
</Button>
</div>
</div>
{/* Re-run confirmation dialog */}
<AlertDialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Re-run recommendations?</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved manual changes. Re-running recommendations will overwrite all groups
with new system suggestions. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={doRerun}>
Re-run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -2,7 +2,7 @@
import { useState, useRef, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { ChevronDown, X } from 'lucide-react'
import { ChevronDown, ChevronRight, X } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface ComboboxOption {
@ -10,8 +10,15 @@ export interface ComboboxOption {
label: string
}
interface ComboboxProps {
export interface ComboboxGroup {
label: string
options: ComboboxOption[]
defaultExpanded?: boolean
}
interface ComboboxProps {
options?: ComboboxOption[]
groups?: ComboboxGroup[]
value: string
onChange: (value: string) => void
placeholder?: string
@ -20,7 +27,8 @@ interface ComboboxProps {
}
export function Combobox({
options,
options = [],
groups,
value,
onChange,
placeholder = 'Select...',
@ -31,11 +39,17 @@ export function Combobox({
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
const initialExpanded = Object.fromEntries(
(groups ?? []).map((g) => [g.label, g.defaultExpanded ?? false])
)
const [expanded, setExpanded] = useState<Record<string, boolean>>(initialExpanded)
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options
const allOptions = groups ? groups.flatMap((g) => g.options) : options
const selected = allOptions.find((o) => o.value === value)
const filteredFlat = query
? allOptions.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: null
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
@ -60,6 +74,25 @@ export function Combobox({
setQuery('')
}
const toggleGroup = (label: string) => {
setExpanded((prev) => ({ ...prev, [label]: !prev[label] }))
}
const renderOption = (option: ComboboxOption, indent = false) => (
<div
key={option.value}
className={cn(
'py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
indent ? 'pl-6 pr-3' : 'px-3',
option.value === value && 'bg-accent font-medium'
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(option)}
>
{option.label}
</div>
)
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
@ -90,22 +123,32 @@ export function Combobox({
{open && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-60 overflow-y-auto">
{filtered.length === 0 ? (
{allOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
) : filteredFlat ? (
filteredFlat.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
) : (
filtered.map((option) => (
<div
key={option.value}
className={cn(
'px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
option.value === value && 'bg-accent font-medium'
)}
filteredFlat.map((o) => renderOption(o))
)
) : groups ? (
groups.map((group) => (
<div key={group.label}>
<button
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide hover:bg-muted/50 transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(option)}
onClick={() => toggleGroup(group.label)}
>
{option.label}
{expanded[group.label]
? <ChevronDown className="h-3 w-3" />
: <ChevronRight className="h-3 w-3" />}
{group.label} ({group.options.length})
</button>
{expanded[group.label] && group.options.map((o) => renderOption(o, true))}
</div>
))
) : (
options.map((o) => renderOption(o))
)}
</div>
)}

View file

@ -0,0 +1,108 @@
import { recommendGroups, PolicyInput } from './renewal-group-recommendations'
function d(iso: string): Date {
return new Date(iso)
}
function p(id: string, exp: string | null): PolicyInput {
return { policyId: id, expirationDate: exp ? d(exp) : null }
}
describe('recommendGroups', () => {
test('empty input returns empty result', () => {
const result = recommendGroups([], { windowDays: 90, rule: 'earliest' })
expect(result.groups).toHaveLength(0)
expect(result.ungroupable).toHaveLength(0)
})
test('single policy becomes a standalone group', () => {
const result = recommendGroups([p('a', '2026-03-15')], { windowDays: 90, rule: 'earliest' })
expect(result.groups).toHaveLength(1)
expect(result.groups[0].policies).toHaveLength(1)
expect(result.ungroupable).toHaveLength(0)
})
test('standalone renewalDate is expirationDate + 1 day', () => {
const result = recommendGroups([p('a', '2026-03-15')], { windowDays: 90, rule: 'earliest' })
expect(result.groups[0].proposedRenewalDate.toISOString().startsWith('2026-03-16')).toBe(true)
})
test('basic 90-day grouping: two close policies grouped together', () => {
const result = recommendGroups(
[p('a', '2026-01-10'), p('b', '2026-03-01')],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups).toHaveLength(1)
expect(result.groups[0].policies).toHaveLength(2)
})
test('basic 90-day grouping: two far-apart policies in separate groups', () => {
const result = recommendGroups(
[p('a', '2026-01-01'), p('b', '2026-06-01')],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups).toHaveLength(2)
})
test('cross-year boundary: Jan and Dec within 90 days are split into separate groups', () => {
const result = recommendGroups(
[p('a', '2025-12-01'), p('b', '2026-01-15')],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups).toHaveLength(1)
expect(result.groups[0].policies).toHaveLength(2)
})
test('rule: earliest picks the minimum expiration date + 1', () => {
const result = recommendGroups(
[p('a', '2026-02-01'), p('b', '2026-04-01')],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups[0].proposedRenewalDate.toISOString().startsWith('2026-02-02')).toBe(true)
})
test('rule: latest picks the maximum expiration date + 1', () => {
const result = recommendGroups(
[p('a', '2026-02-01'), p('b', '2026-04-01')],
{ windowDays: 90, rule: 'latest' }
)
expect(result.groups[0].proposedRenewalDate.toISOString().startsWith('2026-04-02')).toBe(true)
})
test('rule: nearest-to-year-start picks lowest day-of-year + 1', () => {
const result = recommendGroups(
[p('a', '2026-07-15'), p('b', '2026-09-01')],
{ windowDays: 90, rule: 'nearest-to-year-start' }
)
expect(result.groups[0].proposedRenewalDate.toISOString().startsWith('2026-07-16')).toBe(true)
})
test('null expiration date goes into ungroupable', () => {
const result = recommendGroups(
[p('a', '2026-01-01'), p('b', null)],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups).toHaveLength(1)
expect(result.ungroupable).toHaveLength(1)
expect(result.ungroupable[0].policyId).toBe('b')
})
test('all null expiration dates returns no groups and all ungroupable', () => {
const result = recommendGroups(
[p('a', null), p('b', null)],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups).toHaveLength(0)
expect(result.ungroupable).toHaveLength(2)
})
test('three policies: two grouped, one standalone', () => {
const result = recommendGroups(
[p('a', '2026-01-10'), p('b', '2026-02-15'), p('c', '2026-09-01')],
{ windowDays: 90, rule: 'earliest' }
)
expect(result.groups).toHaveLength(2)
expect(result.groups[0].policies).toHaveLength(2)
expect(result.groups[1].policies).toHaveLength(1)
})
})

View file

@ -0,0 +1,107 @@
export interface PolicyInput {
policyId: string
expirationDate: Date | null
}
export type DateRule = 'nearest-to-year-start' | 'earliest' | 'latest'
export interface RecommendationConfig {
windowDays: number
rule: DateRule
}
export interface RecommendedGroup {
policies: PolicyInput[]
proposedRenewalDate: Date
}
export interface RecommendationResult {
groups: RecommendedGroup[]
ungroupable: PolicyInput[]
}
function addDays(date: Date, days: number): Date {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d
}
function dayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0)
const diff = date.getTime() - start.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
function computeRenewalDate(policies: PolicyInput[], rule: DateRule): Date {
const dates = policies.map((p) => p.expirationDate as Date)
let picked: Date
switch (rule) {
case 'earliest':
picked = dates.reduce((a, b) => (a < b ? a : b))
break
case 'latest':
picked = dates.reduce((a, b) => (a > b ? a : b))
break
case 'nearest-to-year-start':
default: {
picked = dates.reduce((a, b) => {
const aDoy = dayOfYear(a)
const bDoy = dayOfYear(b)
return aDoy <= bDoy ? a : b
})
break
}
}
return addDays(picked, 1)
}
export function recommendGroups(
policies: PolicyInput[],
config: RecommendationConfig
): RecommendationResult {
const { windowDays, rule } = config
const today = new Date()
today.setHours(0, 0, 0, 0)
const ungroupable = policies.filter(
(p) => p.expirationDate === null || p.expirationDate < today
)
const groupable = policies
.filter((p) => p.expirationDate !== null && p.expirationDate >= today)
.slice()
.sort((a, b) => (a.expirationDate as Date).getTime() - (b.expirationDate as Date).getTime())
if (groupable.length === 0) {
return { groups: [], ungroupable }
}
const clusters: PolicyInput[][] = []
let current: PolicyInput[] = [groupable[0]]
let windowStart = groupable[0].expirationDate as Date
for (let i = 1; i < groupable.length; i++) {
const policy = groupable[i]
const expDate = policy.expirationDate as Date
const diffDays =
(expDate.getTime() - windowStart.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays <= windowDays) {
current.push(policy)
} else {
clusters.push(current)
current = [policy]
windowStart = expDate
}
}
clusters.push(current)
const groups: RecommendedGroup[] = clusters.map((cluster) => ({
policies: cluster,
proposedRenewalDate: computeRenewalDate(cluster, rule),
}))
return { groups, ungroupable }
}

168
tasks/prd-renewal-groups.md Normal file
View file

@ -0,0 +1,168 @@
# PRD: Renewal Groups — Client/Policy Onboarding Workflow
**Status:** Ready for Development
**Author:** Cascade (from user requirements)
**Date:** 2026-04-09
---
## 1. Introduction / Overview
Renewal Groups logically cluster insurance policies that share a common renewal window. They drive the scheduling of SHAPE tasks and determine the `renewalDate` displayed on client cards throughout the app.
Today, Renewal Groups can be created and managed manually. What is **missing** is a structured onboarding flow that activates when a new client or policy enters the system (via AMS360 sync). This feature adds:
- A **New Client / Policy Setup page** where a Manager or Admin reviews incoming clients/policies that have not yet been configured.
- A **system recommendation engine** that proposes Renewal Group groupings and a default `renewalDate` based on configurable rules.
- A **UI** allowing the Manager/Admin to accept, modify, or override those recommendations before finalising the client's configuration.
---
## 2. Goals
1. Ensure every new client that enters via AMS360 sync gets a `renewalDate` and a `claimsAdvocate` before tasks are generated.
2. Reduce manual configuration effort by surfacing system-generated grouping recommendations.
3. Give Managers/Admins full control to customise the grouping window, default renewal date logic, and per-client overrides.
4. Keep existing Renewal Groups untouched — this flow only applies to unconfigured clients/policies.
---
## 3. User Stories
- **As a Manager**, I want to see a list of clients that have synced from AMS360 but have not been fully configured, so I can quickly identify what needs attention.
- **As a Manager**, I want the system to recommend how to group a new client's policies into Renewal Groups based on their expiration dates, so I don't have to figure it out manually.
- **As a Manager**, I want to customise the grouping window (default 90 days) and the renewal date selection rule (default: earliest date closest to the start of the year within the group), so the defaults match our business practice but can be adjusted.
- **As a Manager**, I want to assign a `claimsAdvocate` and optionally add notes for any new client during setup, so the client is fully configured in one step.
- **As a Manager**, I want to accept, modify, or reject system recommendations for each group before saving, so I remain in control of the final configuration.
- **As an Admin**, I want to be able to reopen the setup wizard for any client at any time (e.g., after a new policy is added mid-year), so I can re-group without losing existing data.
---
## 4. Functional Requirements
### 4.1 New Client Setup Queue
1. The system **must** display a dedicated "New Client Setup" page (or prominent section in the Manager view) listing all clients that:
- Were added via AMS360 sync, **and**
- Are missing either a `renewalDate` **or** a `claimsAdvocate`.
2. Each entry in the queue **must** show: client name, number of policies, earliest and latest policy expiration dates, and how long they have been in the queue (days since sync).
3. The queue **must** be accessible only to users with the **Manager** or **Admin** role.
4. Clicking a client in the queue **must** open the Setup Wizard for that client.
### 4.2 Setup Wizard — Policy Overview
5. The wizard **must** display all policies belonging to the new client with: policy number, type, carrier, and expiration date.
6. Policies with no expiration date **must** be flagged with a visual warning and excluded from auto-grouping recommendations (but still assignable manually).
### 4.3 Recommendation Engine
7. The system **must** group policies into recommended Renewal Groups using the following default logic:
- Policies whose expiration dates fall within a **90-day window** of each other are placed in the same group.
- The window size (90 days) **must** be configurable per-run by the Manager in the wizard UI.
8. Within each recommended group, the system **must** calculate a default `renewalDate` as follows:
- Select the expiration date that is **nearest to the start of the calendar year** (i.e., earliest in JanuaryMarch if available, otherwise the earliest overall) and add **1 day** (`expirationDate + 1`).
- This rule **must** be configurable: the Manager can switch to "earliest in group" or "latest in group" or manually enter a date.
9. The recommendation engine **must** also handle **standalone policies** (policies not grouped with any other policy):
- Their `renewalDate` is `expirationDate + 1`.
- They are displayed as a single-policy "group" in the wizard.
### 4.4 Setup Wizard — Review & Customise
10. The wizard **must** display system-recommended groups as drag-and-drop cards (using `@dnd-kit`), each showing:
- Proposed group name (editable inline).
- Member policies with their expiration dates.
- Proposed `renewalDate` (editable via date picker).
- Renewal date derivation rule selector (nearest-to-year-start / earliest / latest / manual).
11. The Manager **must** be able to:
- Move a policy from one recommended group to another via drag-and-drop or a move selector.
- Split a group by removing a policy and creating a new group from it.
- Merge two groups by dragging one group's policies into another.
- Delete a group (policies revert to ungrouped / standalone).
- Create a blank group manually and add policies to it.
12. The wizard **must** show a live preview of the resulting `renewalDate` for each group as policies are moved.
13. The **90-day grouping window** input **must** be visible at the top of the wizard and re-running it **must** re-compute recommendations (with a confirmation if the Manager has already made manual changes).
### 4.5 Claims Advocate Assignment
14. The wizard **must** include a **Claims Advocate** selector (Claims-department users only) that applies to the whole client.
15. The advocate selector **must** be required before the wizard can be finalised.
### 4.6 Notes
16. The wizard **must** include a free-text notes field for client-level notes and an optional per-group notes field.
### 4.7 Finalisation
17. On "Save & Complete", the system **must**:
- Persist all Renewal Groups (new or modified) to the database.
- Set `client.renewalDate` to the `renewalDate` of the **default renewal group** — the Manager must designate exactly one group as default (radio/star selector on each group card); there is no auto-selection.
- If there is only one group, it is automatically treated as the default without requiring an explicit selection.
- Set `client.claimsAdvocate`.
- Remove the client from the setup queue.
18. The system **must** allow a Manager to **Save as Draft** — keeping the client in the queue but preserving partial work.
19. After finalisation, the wizard **must** be re-openable from the client detail page (e.g., an "Edit Setup" action) for re-grouping when new policies are added.
### 4.8 Renewal Date Display Rules (existing behaviour, confirmed)
20. A policy that belongs to a Renewal Group **must** display `renewalDate` from its group.
21. A standalone policy (no group) **must** display `expirationDate + 1` as its renewal date throughout the app.
22. A client with no policies and no groups **must** show no renewal date until one is manually assigned.
### 4.9 Global Settings (Admin Panel)
23. Admins **must** be able to set **global defaults** in the Admin Panel for:
- Grouping window (default: 90 days).
- Renewal date rule (default: nearest-to-year-start).
These defaults pre-populate the wizard but can be overridden per client per run.
24. Global settings **must not** retroactively change already-finalised Renewal Groups.
---
## 5. Non-Goals (Out of Scope)
- Automated assignment of a `claimsAdvocate` by the system — this always requires a human decision.
- Email or external notifications when new clients enter the queue (in-app only for now).
- Bulk "apply to all" finalisation without per-client review.
- Any changes to how existing, already-configured Renewal Groups behave in the rest of the app.
- Exposing this workflow to Claims Advocates or non-privileged users.
---
## 6. Design Considerations
- The Setup Queue should appear as a **badge/alert on the Manager nav item** showing count of unconfigured clients.
- The wizard should be a **full-page or large modal** flow — not a drawer — given the amount of information.
- Recommended groups should use a **card-based kanban-style layout** so policies can be visually moved between groups.
- Use existing UI conventions: shadcn/ui cards, Tailwind, lucide-react icons, existing Badge/Select/DatePicker components.
- The grouping-window control should be a **number input with a "Re-run Recommendations" button** adjacent to it.
- Drag-and-drop: use `@dnd-kit/core` (already a common pattern in the stack) or a lightweight alternative.
---
## 7. Technical Considerations
- **Database:** `RenewalGroup` model already exists with `renewalDate`, `clientId`, `policyGroupId` relations. A `setupCompletedAt` timestamp on `Client` (nullable) is the simplest way to track queue membership — `NULL` means unconfigured.
- **Queue query:** Clients where `claimsAdvocateId IS NULL OR renewalDate IS NULL` and `createdAt` is post-sync.
- **Recommendation engine:** Pure TypeScript function — accepts a list of `{ policyId, expirationDate }` and config `{ windowDays, rule }`, returns `{ groups: { policies[], proposedRenewalDate }[] }`. Can live in `src/lib/renewal-group-recommendations.ts`.
- **Global settings:** Store in the existing `sync_config` table or a new `app_settings` key-value table. Surfaced in the Admin Panel under a new "Renewal Groups" settings section.
- **Re-run safety:** Track `wizardDirty` client-side; prompt confirmation before overwriting manual changes with a fresh recommendation run.
- **Drag-and-drop:** `@dnd-kit/core` + `@dnd-kit/sortable` for policy cards within and between groups.
---
## 8. Success Metrics
- Zero new clients remain in the setup queue for more than 5 business days after sync.
- Manager setup time per client < 5 minutes for a typical 35 policy client.
- Recommendation acceptance rate (no manual changes) tracked in `shape_import_logs` or a new event log — target ≥ 60%.
---
## 9. Open Questions
~~All questions resolved.~~
1. **Default group**: The Manager explicitly designates exactly one group as the default. No auto-selection.
2. **New policy on existing client**: The recommendation engine runs again and suggests a group or standalone; the Manager reviews and confirms via the wizard re-open flow.
3. **Global settings**: Managed in the Admin Panel.
4. **Drag-and-drop**: `@dnd-kit/core` + `@dnd-kit/sortable` — installed as a project dependency.

View file

@ -0,0 +1,92 @@
## Relevant Files
### New Files
- `ondeck/src/lib/renewal-group-recommendations.ts` - Pure TS recommendation engine: groups policies by expiration window, calculates renewal dates per rule.
- `ondeck/src/lib/renewal-group-recommendations.test.ts` - Unit tests for the recommendation engine.
- `ondeck/src/app/(dashboard)/manager/setup/page.tsx` - New Client Setup Queue page (server component).
- `ondeck/src/app/(dashboard)/manager/setup/[clientId]/page.tsx` - Setup Wizard page for a specific client (server component, fetches client + policies).
- `ondeck/src/components/renewal-groups/setup-wizard.tsx` - Main wizard client component with state, drag-and-drop orchestration, advocate selector, and save logic.
- `ondeck/src/components/renewal-groups/group-card.tsx` - Droppable group card showing policies, inline name editor, renewal date picker, rule selector, and default-group radio.
- `ondeck/src/components/renewal-groups/policy-chip.tsx` - Draggable policy pill used inside group cards and the unassigned policies tray.
- `ondeck/src/app/api/clients/setup-queue/route.ts` - GET: returns unconfigured clients (missing `renewalDate` or `claimsAdvocate`).
- `ondeck/src/app/api/clients/[id]/setup/route.ts` - POST: persists wizard output (groups, renewalDate, claimsAdvocate, notes, setupCompletedAt). PUT: saves draft.
- `ondeck/src/app/api/admin/renewal-settings/route.ts` - GET/PUT: reads and writes global renewal group defaults from `app_settings`.
- `ondeck/src/app/(dashboard)/admin/renewal-settings/page.tsx` - Admin Panel page for global defaults (grouping window, renewal date rule).
### Modified Files
- `ondeck/prisma/schema.prisma` - Add `setupCompletedAt DateTime?` to `Client`; add new `AppSetting` model for key-value settings store.
- `ondeck/src/app/(dashboard)/layout.tsx` - Add badge on Manager nav item showing count of unconfigured clients.
- `ondeck/src/app/(dashboard)/manager/page.tsx` - Add "New Client Setup" summary card linking to the queue page.
- `ondeck/src/app/(dashboard)/clients/[id]/page.tsx` - Pass `setupCompletedAt` and policies to `ClientDetail`; add "Edit Setup" button for Manager/Admin.
- `ondeck/src/components/clients/client-detail.tsx` - Render "Edit Setup" button that navigates to the wizard for the current client.
- `ondeck/src/app/(dashboard)/admin/page.tsx` - Add "Renewal Groups" section linking to the new settings page.
### Notes
- Unit tests should be placed alongside source files (e.g., `renewal-group-recommendations.ts` and `renewal-group-recommendations.test.ts` in the same directory).
- Run tests with `npx jest src/lib/renewal-group-recommendations`.
- After any `schema.prisma` change, run `npx prisma migrate dev --name <migration-name>` and `npx prisma generate`.
- `@dnd-kit/core`, `@dnd-kit/sortable`, and `@dnd-kit/utilities` are already installed.
---
## Tasks
- [x] 1.0 Database & Schema
- [x] 1.1 Add `setupCompletedAt DateTime? @map("setup_completed_at")` field to the `Client` model in `prisma/schema.prisma`.
- [x] 1.2 Add a new `AppSetting` model to `prisma/schema.prisma` with fields `key String @id`, `value String @db.Text`, `updatedAt DateTime @updatedAt`. Map to table `app_settings`.
- [x] 1.3 Run `npx prisma migrate dev --name add_setup_completed_at_and_app_settings` to create and apply the migration. (Used `prisma db push` due to pre-existing schema drift.)
- [x] 1.4 Run `npx prisma generate` to update the Prisma client.
- [x] 1.5 Seed two default rows into `app_settings`: `renewal_group_window_days = "90"` and `renewal_group_date_rule = "nearest-to-year-start"`.
- [x] 2.0 Recommendation Engine
- [x] 2.1 Create `src/lib/renewal-group-recommendations.ts`. Define and export the input types: `PolicyInput { policyId: string; expirationDate: Date }` and `RecommendationConfig { windowDays: number; rule: 'nearest-to-year-start' | 'earliest' | 'latest' }`.
- [x] 2.2 Implement the grouping algorithm: sort policies by `expirationDate`, then use a sliding-window approach to cluster policies whose dates fall within `windowDays` of each other into the same group.
- [x] 2.3 Implement the `renewalDate` calculation for each group based on `config.rule`: `nearest-to-year-start` picks the expiration date with the lowest day-of-year value; `earliest` picks the minimum; `latest` picks the maximum. All results are `expirationDate + 1 day`.
- [x] 2.4 Handle standalone policies (groups of size 1) — they are valid output and their `renewalDate` is simply `expirationDate + 1`.
- [x] 2.5 Policies with a `null` expiration date must be excluded from grouping and returned separately as `ungroupable: PolicyInput[]`.
- [x] 2.6 Write unit tests in `renewal-group-recommendations.test.ts` covering: basic 90-day grouping, cross-year-boundary grouping, all three date rules, standalone policy, null-date exclusion, and empty input. (12/12 pass)
- [x] 3.0 New Client Setup Queue
- [x] 3.1 Create `src/app/api/clients/setup-queue/route.ts` — GET handler that queries clients where `claimsAdvocateId IS NULL OR setupCompletedAt IS NULL`, including policy count, earliest/latest expiration dates, and `createdAt`. Returns sorted by `createdAt ASC`.
- [x] 3.2 Create `src/app/(dashboard)/manager/setup/page.tsx` — server component that fetches the queue via Prisma and renders a table/list of unconfigured clients. Restrict to Manager/Admin roles; redirect others.
- [x] 3.3 Each queue row must show: client name (linked to wizard), policy count, earliest expiry, latest expiry, days in queue (today `createdAt`).
- [x] 3.4 Add a "Days in queue" warning: highlight rows where `daysInQueue > 5` in amber, `> 10` in red.
- [x] 3.5 Update `src/components/layout/nav-bar.tsx` to fetch the unconfigured client count client-side and show a numeric badge on the Manager nav link when count > 0.
- [x] 3.6 Update `src/app/(dashboard)/manager/page.tsx` and `manager-page-client.tsx` to add a "New Client Setup" summary card showing the queue count and a link to `/manager/setup`.
- [x] 4.0 Setup Wizard UI
- [x] 4.1 Create `src/app/(dashboard)/manager/setup/[clientId]/page.tsx` — server component that fetches the client, all its policies, existing policy groups, Claims-dept users, and global settings. Passes all data to `<SetupWizard />`.
- [x] 4.2 Create `src/components/renewal-groups/setup-wizard.tsx` — client component with full state management.
- [x] 4.3 On mount, if no existing groups, auto-run the recommendation engine. If existing groups already exist, load them directly.
- [x] 4.4 Render a top toolbar in the wizard: `windowDays` number input, `rule` select dropdown, and a "Re-run Recommendations" button with dirty-state confirmation dialog.
- [x] 4.5 Create `src/components/renewal-groups/policy-chip.tsx``useDraggable` component with no-expiry warning.
- [x] 4.6 Create `src/components/renewal-groups/group-card.tsx``useDroppable` card with inline name editor, rule selector, live renewal date, manual override, notes, and default-group star.
- [x] 4.7 Render an "Unassigned Policies" tray at the bottom of the wizard.
- [x] 4.8 Implement policy drag-and-drop via `@dnd-kit/core`.
- [x] 4.9 Add group management: "Add Group" button, trash icon to delete, "Split" context action on each chip.
- [x] 4.10 Add Claims Advocate selector (filtered to Claims dept). "Save & Complete" disabled if no advocate.
- [x] 4.11 Single-default-group rule with star radio buttons; hidden when only one group.
- [x] 4.12 "Save & Complete" — validates, calls POST, navigates to client detail on success.
- [x] 4.13 "Save as Draft" — calls PUT, shows toast, keeps `setupCompletedAt` null.
- [x] 4.14 Breadcrumb/back link to the queue page at the top of the wizard.
- [x] 5.0 API Routes
- [x] 5.1 Create `src/app/api/clients/[id]/setup/route.ts` — POST handler with Prisma transaction: upsert groups, update policy assignments, set client fields including `setupCompletedAt`.
- [x] 5.2 PUT handler on same route for draft saves (omits `setupCompletedAt`).
- [x] 5.3 Both handlers protected: return `403` if not Manager or Admin.
- [x] 5.4 Create `src/app/api/admin/renewal-settings/route.ts` — GET/PUT with Admin-only write guard.
- [x] 5.5 `setup-queue/route.ts` accepts `?count=true` query param returning `{ count: number }`.
- [x] 6.0 Admin Panel — Global Settings
- [x] 6.1 Create `src/app/(dashboard)/admin/renewal-settings/page.tsx` — server component reading settings and rendering `<RenewalSettingsForm />`.
- [x] 6.2 Settings form: number input for window days (1365) and select for date rule.
- [x] 6.3 On save, calls `PUT /api/admin/renewal-settings`. Shows success/error toast.
- [x] 6.4 Updated `src/app/(dashboard)/admin/page.tsx` with a "Renewal Groups" card linking to `/admin/renewal-settings`.
- [x] 7.0 Re-open Wizard from Client Detail
- [x] 7.1 Updated `src/app/(dashboard)/clients/[id]/page.tsx` to pass `setupCompletedAt` and `canManageSetup` props.
- [x] 7.2 Updated `src/components/clients/client-detail.tsx` to render "Edit Setup" / "Complete Setup" button for Manager/Admin.
- [x] 7.3 Wizard loads existing `PolicyGroup` data when re-opened and shows a re-configure banner.
- [x] 7.4 API POST handler deletes removed groups (DB groups not in payload) inside the same transaction.