Renewal settings, client setup queue, manager setup page, combobox grouping, nav updates
This commit is contained in:
parent
d109a5f73a
commit
4315f704c7
29 changed files with 2588 additions and 65 deletions
8
ondeck/docs/Renewal Groups.md
Normal file
8
ondeck/docs/Renewal Groups.md
Normal 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.
|
||||||
56
ondeck/package-lock.json
generated
56
ondeck/package-lock.json
generated
|
|
@ -9,6 +9,9 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@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/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
|
@ -1111,6 +1114,59 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@electric-sql/pglite": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@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/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,8 @@ model Client {
|
||||||
parentClientId String? @map("parent_client_id")
|
parentClientId String? @map("parent_client_id")
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
customFields Json @default("{}") @map("custom_fields")
|
customFields Json @default("{}") @map("custom_fields")
|
||||||
|
renewalDate DateTime? @map("renewal_date")
|
||||||
|
setupCompletedAt DateTime? @map("setup_completed_at")
|
||||||
lastSyncedAt DateTime? @map("last_synced_at")
|
lastSyncedAt DateTime? @map("last_synced_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
@ -405,6 +407,14 @@ model SyncConfig {
|
||||||
@@map("sync_config")
|
@@map("sync_config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AppSetting {
|
||||||
|
key String @id
|
||||||
|
value String @db.Text
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("app_settings")
|
||||||
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import Link from 'next/link'
|
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() {
|
export default async function AdminPage() {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
|
|
@ -75,6 +75,14 @@ export default async function AdminPage() {
|
||||||
color: 'text-teal-600',
|
color: 'text-teal-600',
|
||||||
bgColor: 'bg-teal-100',
|
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 (
|
return (
|
||||||
|
|
|
||||||
47
ondeck/src/app/(dashboard)/admin/renewal-settings/page.tsx
Normal file
47
ondeck/src/app/(dashboard)/admin/renewal-settings/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
ondeck/src/app/(dashboard)/admin/settings/page.tsx
Normal file
107
ondeck/src/app/(dashboard)/admin/settings/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ export default async function ClientDetailPage({
|
||||||
parentClient: {
|
parentClient: {
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
},
|
},
|
||||||
|
// setupCompletedAt and renewalDate are scalar fields, included automatically
|
||||||
subsidiaries: {
|
subsidiaries: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -160,6 +161,8 @@ export default async function ClientDetailPage({
|
||||||
policyGroups={policyGroups}
|
policyGroups={policyGroups}
|
||||||
allPolicies={allPoliciesData}
|
allPolicies={allPoliciesData}
|
||||||
canManageGroups={canManageGroups}
|
canManageGroups={canManageGroups}
|
||||||
|
canManageSetup={canManageGroups}
|
||||||
|
setupCompletedAt={client.setupCompletedAt?.toISOString() ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default async function ManagerPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch team statistics
|
// 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(),
|
||||||
prisma.user.count({ where: { isActive: true } }),
|
prisma.user.count({ where: { isActive: true } }),
|
||||||
prisma.task.count(),
|
prisma.task.count(),
|
||||||
|
|
@ -30,6 +30,15 @@ export default async function ManagerPage() {
|
||||||
status: { not: 'COMPLETED' }
|
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
|
// Fetch team members with their task counts
|
||||||
|
|
@ -99,6 +108,7 @@ export default async function ManagerPage() {
|
||||||
overdueTasks={overdueTasks}
|
overdueTasks={overdueTasks}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
recentTasks={recentTasks}
|
recentTasks={recentTasks}
|
||||||
|
setupQueueCount={setupQueueCount}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
ondeck/src/app/(dashboard)/manager/setup/[clientId]/page.tsx
Normal file
103
ondeck/src/app/(dashboard)/manager/setup/[clientId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
ondeck/src/app/(dashboard)/manager/setup/page.tsx
Normal file
186
ondeck/src/app/(dashboard)/manager/setup/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
ondeck/src/app/api/admin/renewal-settings/route.ts
Normal file
72
ondeck/src/app/api/admin/renewal-settings/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
61
ondeck/src/app/api/admin/sync-config/route.ts
Normal file
61
ondeck/src/app/api/admin/sync-config/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
134
ondeck/src/app/api/clients/[id]/setup/route.ts
Normal file
134
ondeck/src/app/api/clients/[id]/setup/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ondeck/src/app/api/clients/setup-queue/route.ts
Normal file
86
ondeck/src/app/api/clients/setup-queue/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,8 +20,8 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
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 { 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 } from '@/components/ui/combobox'
|
import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
|
||||||
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||||
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
|
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
|
||||||
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
|
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
|
||||||
|
|
@ -31,6 +31,7 @@ interface SimpleUser {
|
||||||
id: string
|
id: string
|
||||||
displayName: string | null
|
displayName: string | null
|
||||||
email: string
|
email: string
|
||||||
|
department?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientDetailProps {
|
interface ClientDetailProps {
|
||||||
|
|
@ -39,9 +40,11 @@ interface ClientDetailProps {
|
||||||
policyGroups?: any[]
|
policyGroups?: any[]
|
||||||
allPolicies?: any[]
|
allPolicies?: any[]
|
||||||
canManageGroups?: boolean
|
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 [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
|
||||||
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
|
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -331,9 +334,21 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parent / Child badges — right-aligned */}
|
{/* Right-side header actions */}
|
||||||
{(isParent || isChild) && (
|
<div className="flex flex-wrap gap-2 shrink-0 pt-1 items-start">
|
||||||
<div className="flex flex-wrap gap-2 shrink-0 pt-1">
|
{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 && (
|
{isParent && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubsidiariesModalOpen(true)}
|
onClick={() => setSubsidiariesModalOpen(true)}
|
||||||
|
|
@ -353,7 +368,6 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subsidiaries modal */}
|
{/* Subsidiaries modal */}
|
||||||
|
|
@ -956,9 +970,21 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Combobox
|
<Combobox
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
options={allUsers
|
groups={(() => {
|
||||||
.filter((u) => !members.some((m: any) => m.userId === u.id))
|
const available = allUsers.filter((u) => !members.some((m: any) => m.userId === u.id))
|
||||||
.map((u) => ({ value: u.id, label: u.displayName || u.email }))}
|
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}
|
value={addMemberId}
|
||||||
onChange={setAddMemberId}
|
onChange={setAddMemberId}
|
||||||
placeholder="Add member..."
|
placeholder="Add member..."
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { ClientCard } from './client-card'
|
import { ClientCard } from './client-card'
|
||||||
import { ClientTable } from './client-table'
|
import { ClientTable } from './client-table'
|
||||||
|
import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
|
||||||
import { Search, Filter, LayoutGrid, List, X, EyeOff, Eye } from 'lucide-react'
|
import { Search, Filter, LayoutGrid, List, X, EyeOff, Eye } from 'lucide-react'
|
||||||
|
|
||||||
type ViewMode = 'cards' | 'table'
|
type ViewMode = 'cards' | 'table'
|
||||||
|
|
@ -152,17 +153,26 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={teamMemberFilter || '_all'} onValueChange={handleFilterChange(setTeamMemberFilter)}>
|
<Combobox
|
||||||
<SelectTrigger className="w-[180px]">
|
className="w-[180px]"
|
||||||
<SelectValue placeholder="All Team Members" />
|
placeholder="All Team Members"
|
||||||
</SelectTrigger>
|
value={teamMemberFilter}
|
||||||
<SelectContent>
|
onChange={(v) => { setTeamMemberFilter(v); setPage(1) }}
|
||||||
<SelectItem value="_all">All Team Members</SelectItem>
|
groups={(() => {
|
||||||
{users.map((u) => (
|
const toOption = (u: SimpleUser) => ({ value: u.id, label: u.displayName || u.email })
|
||||||
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
|
const claims = users.filter((u) => u.department?.toLowerCase().includes('claims'))
|
||||||
))}
|
const others = users.filter((u) => !u.department?.toLowerCase().includes('claims'))
|
||||||
</SelectContent>
|
const deptMap = new Map<string, SimpleUser[]>()
|
||||||
</Select>
|
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
|
<Button
|
||||||
variant={showNoPolicies ? 'secondary' : 'outline'}
|
variant={showNoPolicies ? 'secondary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { signOut, useSession } from 'next-auth/react'
|
import { signOut, useSession } from 'next-auth/react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -21,6 +22,15 @@ export function NavBar() {
|
||||||
const userRoles = (session?.user as any)?.roles || []
|
const userRoles = (session?.user as any)?.roles || []
|
||||||
const isAdmin = userRoles.includes('Admin')
|
const isAdmin = userRoles.includes('Admin')
|
||||||
const isManager = userRoles.includes('Manager') || isAdmin
|
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 = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
|
@ -29,7 +39,7 @@ export function NavBar() {
|
||||||
]
|
]
|
||||||
|
|
||||||
if (isManager) {
|
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 })
|
navItems.push({ href: '/tasks/assign', label: 'Assign Tasks', icon: UserPlus })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,11 +63,12 @@ export function NavBar() {
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const isActive = pathname.startsWith(item.href)
|
const isActive = pathname.startsWith(item.href)
|
||||||
|
const badge = (item as any).badge
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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
|
isActive
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
|
@ -65,6 +76,11 @@ export function NavBar() {
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.label}
|
{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>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { useState, useMemo } from 'react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
|
||||||
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
|
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
|
|
@ -16,6 +17,7 @@ interface ManagerPageClientProps {
|
||||||
overdueTasks: number
|
overdueTasks: number
|
||||||
teamMembers: any[]
|
teamMembers: any[]
|
||||||
recentTasks: any[]
|
recentTasks: any[]
|
||||||
|
setupQueueCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManagerPageClient({
|
export function ManagerPageClient({
|
||||||
|
|
@ -25,6 +27,7 @@ export function ManagerPageClient({
|
||||||
overdueTasks,
|
overdueTasks,
|
||||||
teamMembers,
|
teamMembers,
|
||||||
recentTasks,
|
recentTasks,
|
||||||
|
setupQueueCount,
|
||||||
}: ManagerPageClientProps) {
|
}: ManagerPageClientProps) {
|
||||||
const [claimsOnly, setClaimsOnly] = useState(true)
|
const [claimsOnly, setClaimsOnly] = useState(true)
|
||||||
|
|
||||||
|
|
@ -95,6 +98,22 @@ export function ManagerPageClient({
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<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">
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
|
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
|
||||||
|
|
|
||||||
196
ondeck/src/components/renewal-groups/group-card.tsx
Normal file
196
ondeck/src/components/renewal-groups/group-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
ondeck/src/components/renewal-groups/policy-chip.tsx
Normal file
74
ondeck/src/components/renewal-groups/policy-chip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
601
ondeck/src/components/renewal-groups/setup-wizard.tsx
Normal file
601
ondeck/src/components/renewal-groups/setup-wizard.tsx
Normal 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 & 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 & 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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { ChevronDown, X } from 'lucide-react'
|
import { ChevronDown, ChevronRight, X } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export interface ComboboxOption {
|
export interface ComboboxOption {
|
||||||
|
|
@ -10,8 +10,15 @@ export interface ComboboxOption {
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComboboxProps {
|
export interface ComboboxGroup {
|
||||||
|
label: string
|
||||||
options: ComboboxOption[]
|
options: ComboboxOption[]
|
||||||
|
defaultExpanded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComboboxProps {
|
||||||
|
options?: ComboboxOption[]
|
||||||
|
groups?: ComboboxGroup[]
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
|
@ -20,7 +27,8 @@ interface ComboboxProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
options,
|
options = [],
|
||||||
|
groups,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Select...',
|
placeholder = 'Select...',
|
||||||
|
|
@ -31,11 +39,17 @@ export function Combobox({
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
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
|
const allOptions = groups ? groups.flatMap((g) => g.options) : options
|
||||||
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
const selected = allOptions.find((o) => o.value === value)
|
||||||
: options
|
|
||||||
|
const filteredFlat = query
|
||||||
|
? allOptions.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
: null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
|
@ -60,6 +74,25 @@ export function Combobox({
|
||||||
setQuery('')
|
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 (
|
return (
|
||||||
<div ref={containerRef} className={cn('relative', className)}>
|
<div ref={containerRef} className={cn('relative', className)}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -90,22 +123,32 @@ export function Combobox({
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-60 overflow-y-auto">
|
<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>
|
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((option) => (
|
filteredFlat.map((o) => renderOption(o))
|
||||||
<div
|
)
|
||||||
key={option.value}
|
) : groups ? (
|
||||||
className={cn(
|
groups.map((group) => (
|
||||||
'px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
|
<div key={group.label}>
|
||||||
option.value === value && 'bg-accent font-medium'
|
<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()}
|
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>
|
</div>
|
||||||
))
|
))
|
||||||
|
) : (
|
||||||
|
options.map((o) => renderOption(o))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
108
ondeck/src/lib/renewal-group-recommendations.test.ts
Normal file
108
ondeck/src/lib/renewal-group-recommendations.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
107
ondeck/src/lib/renewal-group-recommendations.ts
Normal file
107
ondeck/src/lib/renewal-group-recommendations.ts
Normal 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
168
tasks/prd-renewal-groups.md
Normal 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 January–March 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 3–5 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.
|
||||||
92
tasks/tasks-prd-renewal-groups.md
Normal file
92
tasks/tasks-prd-renewal-groups.md
Normal 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 (1–365) 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.
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue