diff --git a/ondeck/docs/Renewal Groups.md b/ondeck/docs/Renewal Groups.md new file mode 100644 index 0000000..4a5b219 --- /dev/null +++ b/ondeck/docs/Renewal Groups.md @@ -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. \ No newline at end of file diff --git a/ondeck/package-lock.json b/ondeck/package-lock.json index 1f9e58c..6f56825 100644 --- a/ondeck/package-lock.json +++ b/ondeck/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@radix-ui/react-avatar": "^1.1.11", @@ -1111,6 +1114,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", diff --git a/ondeck/package.json b/ondeck/package.json index 63d37b9..8e91524 100644 --- a/ondeck/package.json +++ b/ondeck/package.json @@ -19,6 +19,9 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@radix-ui/react-avatar": "^1.1.11", diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma index dec11db..ffe0303 100644 --- a/ondeck/prisma/schema.prisma +++ b/ondeck/prisma/schema.prisma @@ -126,6 +126,8 @@ model Client { parentClientId String? @map("parent_client_id") notes String? @db.Text customFields Json @default("{}") @map("custom_fields") + renewalDate DateTime? @map("renewal_date") + setupCompletedAt DateTime? @map("setup_completed_at") lastSyncedAt DateTime? @map("last_synced_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -405,6 +407,14 @@ model SyncConfig { @@map("sync_config") } +model AppSetting { + key String @id + value String @db.Text + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("app_settings") +} + model AuditLog { id String @id @default(cuid()) userId String? @map("user_id") diff --git a/ondeck/src/app/(dashboard)/admin/page.tsx b/ondeck/src/app/(dashboard)/admin/page.tsx index f7085b0..252f465 100644 --- a/ondeck/src/app/(dashboard)/admin/page.tsx +++ b/ondeck/src/app/(dashboard)/admin/page.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import Link from 'next/link' -import { Shapes, FileText, Users, Database, Settings, ClipboardList, History } from 'lucide-react' +import { Shapes, FileText, Users, Database, Settings, ClipboardList, History, CalendarRange } from 'lucide-react' export default async function AdminPage() { const session = await getServerSession(authOptions) @@ -75,6 +75,14 @@ export default async function AdminPage() { color: 'text-teal-600', bgColor: 'bg-teal-100', }, + { + title: 'Renewal Groups', + description: 'Configure default grouping window and renewal date rules for the client setup wizard', + icon: CalendarRange, + href: '/admin/renewal-settings', + color: 'text-indigo-600', + bgColor: 'bg-indigo-100', + }, ] return ( diff --git a/ondeck/src/app/(dashboard)/admin/renewal-settings/page.tsx b/ondeck/src/app/(dashboard)/admin/renewal-settings/page.tsx new file mode 100644 index 0000000..6dea1bf --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/renewal-settings/page.tsx @@ -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 ( +
+
+ + Admin + + / + Renewal Groups +
+ +
+

Renewal Groups — Default Settings

+

+ These defaults pre-populate the setup wizard for new clients. They do not change existing + renewal groups. +

+
+ + +
+ ) +} diff --git a/ondeck/src/app/(dashboard)/admin/renewal-settings/renewal-settings-form.tsx b/ondeck/src/app/(dashboard)/admin/renewal-settings/renewal-settings-form.tsx new file mode 100644 index 0000000..13abe0c --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/renewal-settings/renewal-settings-form.tsx @@ -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 ( + + +
+ + setWindowDays(Number(e.target.value))} + className="w-32" + /> +

+ Policies with expiration dates within this many days of each other are grouped together. +

+
+ +
+ + +

+ Determines which expiration date within a group becomes the renewal date (+1 day). +

+
+ + +
+
+ ) +} diff --git a/ondeck/src/app/(dashboard)/admin/settings/page.tsx b/ondeck/src/app/(dashboard)/admin/settings/page.tsx new file mode 100644 index 0000000..f5ce371 --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/settings/page.tsx @@ -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 ( +
+
+ + Admin + + / + System Settings +
+ +
+

+ System Settings +

+

+ System-wide configuration. Changes take effect immediately. +

+
+ + {/* Sync config inline editor */} + + + {/* Links to subsection settings pages */} +
+

Feature Settings

+
+ {relatedPages.map((page) => { + const Icon = page.icon + return ( + + + +
+
+ +
+ {page.title} + {page.description} +
+
+
+ {page.values.map((v, i) => ( + + {v} + + ))} + +
+
+
+
+ + ) + })} +
+
+
+ ) +} diff --git a/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx b/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx new file mode 100644 index 0000000..b0810ff --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx @@ -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 ( + + + + Sync Configuration + + + +
+
+ +

+ Runs the AFW → Horizon data sync on the configured schedule. +

+
+ +
+ +
+ + setSyncSchedule(e.target.value)} + className="font-mono w-48 text-sm" + placeholder="0 2 * * *" + /> +

+ Default 0 2 * * * runs daily at 2 AM. + Changes require a systemd timer restart to take effect. +

+
+ + +
+
+ ) +} diff --git a/ondeck/src/app/(dashboard)/clients/[id]/page.tsx b/ondeck/src/app/(dashboard)/clients/[id]/page.tsx index 9db7c59..90d69af 100644 --- a/ondeck/src/app/(dashboard)/clients/[id]/page.tsx +++ b/ondeck/src/app/(dashboard)/clients/[id]/page.tsx @@ -35,6 +35,7 @@ export default async function ClientDetailPage({ parentClient: { select: { id: true, name: true }, }, + // setupCompletedAt and renewalDate are scalar fields, included automatically subsidiaries: { select: { id: true, @@ -160,6 +161,8 @@ export default async function ClientDetailPage({ policyGroups={policyGroups} allPolicies={allPoliciesData} canManageGroups={canManageGroups} + canManageSetup={canManageGroups} + setupCompletedAt={client.setupCompletedAt?.toISOString() ?? null} /> ) diff --git a/ondeck/src/app/(dashboard)/manager/page.tsx b/ondeck/src/app/(dashboard)/manager/page.tsx index fe9b9c2..4c0eba8 100644 --- a/ondeck/src/app/(dashboard)/manager/page.tsx +++ b/ondeck/src/app/(dashboard)/manager/page.tsx @@ -19,7 +19,7 @@ export default async function ManagerPage() { } // Fetch team statistics - const [, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([ + const [, activeUsers, totalTasks, completedTasks, overdueTasks, setupQueueCount] = await Promise.all([ prisma.user.count(), prisma.user.count({ where: { isActive: true } }), prisma.task.count(), @@ -30,6 +30,15 @@ export default async function ManagerPage() { status: { not: 'COMPLETED' } } }), + prisma.client.count({ + where: { + designation: { name: { in: ['Shape', 'Shape 2'] } }, + OR: [ + { claimsAdvocateId: null }, + { setupCompletedAt: null }, + ], + }, + }), ]) // Fetch team members with their task counts @@ -99,6 +108,7 @@ export default async function ManagerPage() { overdueTasks={overdueTasks} teamMembers={teamMembers} recentTasks={recentTasks} + setupQueueCount={setupQueueCount} /> ) } diff --git a/ondeck/src/app/(dashboard)/manager/setup/[clientId]/page.tsx b/ondeck/src/app/(dashboard)/manager/setup/[clientId]/page.tsx new file mode 100644 index 0000000..8add34f --- /dev/null +++ b/ondeck/src/app/(dashboard)/manager/setup/[clientId]/page.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/ondeck/src/app/(dashboard)/manager/setup/page.tsx b/ondeck/src/app/(dashboard)/manager/setup/page.tsx new file mode 100644 index 0000000..ad744d3 --- /dev/null +++ b/ondeck/src/app/(dashboard)/manager/setup/page.tsx @@ -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 ( +
+
+
+

New Client Setup

+

+ Clients that need a claims advocate or renewal group configuration. +

+
+ + {rows.length} pending + +
+ + {rows.length === 0 ? ( + + + +

All clients are configured

+

No clients are awaiting setup.

+
+
+ ) : ( + + + Pending Setup + + Click a client name to open the setup wizard. + + + +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + + ) + })} + +
ClientPoliciesEarliest RenewalLatest RenewalIssuesIn Queue +
+ + {row.name} + + {row.policyCount} + {formatDate(row.earliestRenewal)} + + {formatDate(row.latestRenewal)} + +
+ {row.missingAdvocate && ( + + No Advocate + + )} + {row.missingSetup && ( + + No Setup + + )} +
+
+ {row.daysInQueue}d + + + Configure + +
+
+
+
+ )} +
+ ) +} diff --git a/ondeck/src/app/api/admin/renewal-settings/route.ts b/ondeck/src/app/api/admin/renewal-settings/route.ts new file mode 100644 index 0000000..c8ba46f --- /dev/null +++ b/ondeck/src/app/api/admin/renewal-settings/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/admin/sync-config/route.ts b/ondeck/src/app/api/admin/sync-config/route.ts new file mode 100644 index 0000000..bab21a1 --- /dev/null +++ b/ondeck/src/app/api/admin/sync-config/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/clients/[id]/setup/route.ts b/ondeck/src/app/api/clients/[id]/setup/route.ts new file mode 100644 index 0000000..e947669 --- /dev/null +++ b/ondeck/src/app/api/clients/[id]/setup/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/clients/setup-queue/route.ts b/ondeck/src/app/api/clients/setup-queue/route.ts new file mode 100644 index 0000000..86a3aba --- /dev/null +++ b/ondeck/src/app/api/clients/setup-queue/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index 7144b94..584b8d0 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -20,8 +20,8 @@ import { SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight } from 'lucide-react' -import { Combobox } from '@/components/ui/combobox' +import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight, Settings2 } from 'lucide-react' +import { Combobox, type ComboboxGroup } from '@/components/ui/combobox' import { formatDate, formatRenewalDate } from '@/lib/utils' import { PolicyGroupManager } from '@/components/clients/policy-group-manager' import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal' @@ -31,6 +31,7 @@ interface SimpleUser { id: string displayName: string | null email: string + department?: string | null } interface ClientDetailProps { @@ -39,9 +40,11 @@ interface ClientDetailProps { policyGroups?: any[] allPolicies?: any[] canManageGroups?: boolean + canManageSetup?: boolean + setupCompletedAt?: string | null } -export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false }: ClientDetailProps) { +export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false, canManageSetup = false, setupCompletedAt }: ClientDetailProps) { const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '') const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '') const [saving, setSaving] = useState(false) @@ -331,29 +334,40 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic - {/* Parent / Child badges — right-aligned */} - {(isParent || isChild) && ( -
- {isParent && ( - - )} - {isChild && ( - - - Child of {parentClient!.name} - - )} -
- )} + {/* Right-side header actions */} +
+ {canManageSetup && ( + + + {setupCompletedAt ? 'Edit Setup' : 'Complete Setup'} + + )} + {isParent && ( + + )} + {isChild && ( + + + Child of {parentClient!.name} + + )} +
{/* Subsidiaries modal */} @@ -956,9 +970,21 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
!members.some((m: any) => m.userId === u.id)) - .map((u) => ({ value: u.id, label: u.displayName || u.email }))} + groups={(() => { + const available = allUsers.filter((u) => !members.some((m: any) => m.userId === u.id)) + const toOption = (u: SimpleUser) => ({ value: u.id, label: u.displayName || u.email }) + const claims = available.filter((u) => u.department?.toLowerCase().includes('claims')) + const others = available.filter((u) => !u.department?.toLowerCase().includes('claims')) + const deptMap = new Map() + others.forEach((u) => { + const dept = u.department || 'Other' + deptMap.set(dept, [...(deptMap.get(dept) ?? []), u]) + }) + const groups: ComboboxGroup[] = [] + if (claims.length) groups.push({ label: 'Claims', options: claims.map(toOption), defaultExpanded: true }) + deptMap.forEach((users, dept) => groups.push({ label: dept, options: users.map(toOption), defaultExpanded: false })) + return groups + })()} value={addMemberId} onChange={setAddMemberId} placeholder="Add member..." diff --git a/ondeck/src/components/clients/client-list.tsx b/ondeck/src/components/clients/client-list.tsx index 3dd1828..9b2d6eb 100644 --- a/ondeck/src/components/clients/client-list.tsx +++ b/ondeck/src/components/clients/client-list.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/select' import { ClientCard } from './client-card' import { ClientTable } from './client-table' +import { Combobox, type ComboboxGroup } from '@/components/ui/combobox' import { Search, Filter, LayoutGrid, List, X, EyeOff, Eye } from 'lucide-react' type ViewMode = 'cards' | 'table' @@ -152,17 +153,26 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis ))} - + { setTeamMemberFilter(v); setPage(1) }} + groups={(() => { + const toOption = (u: SimpleUser) => ({ value: u.id, label: u.displayName || u.email }) + const claims = users.filter((u) => u.department?.toLowerCase().includes('claims')) + const others = users.filter((u) => !u.department?.toLowerCase().includes('claims')) + const deptMap = new Map() + 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 + })()} + /> + )} + +
+ + + + +
+ {group.policies.length === 0 ? ( +

+ Drop policies here +

+ ) : ( + group.policies.map((policy) => ( +
+ + {group.policies.length > 1 && ( + + )} +
+ )) + )} +
+ +
+ +