Timezone (all users EST): - lib/utils: hardcode APP_TIME_ZONE (America/New_York) in formatDate/ formatRenewalDate, add formatDateTime and todayInAppTimeZone helpers - Replace every ad-hoc toLocaleDateString/toLocaleString call across the app (task notes, audit log, backups, shape import, renewal groups, client detail) with the shared EST-aware helpers - Fix UTC "today" bug in date-input defaults/min/max (completion date, reminder date) that rolled to the next calendar day after ~7-8pm ET Task provenance: - Task card info icon (now visible to all users, not just privileged) shows full origin: template, level, renewal anchor, due-date math, and who/what generated the task - Include template.daysOffset and creator in task queries Setup N/A: - New setupNaAt/setupNaReason fields on Client; mark/restore UI and API to exclude non-Shape/lost-business clients from the setup queue everywhere it's counted (queue page, API, manager dashboard, metrics gauge) Task generation fixes: - auto-generate: client-level branch now gated on the renewal anchor's (group/policy) createdAt instead of the client's, so pre-go-live clients with new post-go-live groups are no longer skipped forever - New auto-assign-tasks sweep run after every sync to assign advocates to tasks created by paths that don't assign directly (e.g. setup wizard)
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
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'
|
|
import { SetupNaButton, SetupNaUndoButton } from '@/components/clients/setup-na-button'
|
|
import { formatDate as formatDateEst } from '@/lib/utils'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
function formatDate(d: Date | null): string {
|
|
if (!d) return '—'
|
|
return formatDateEst(d)
|
|
}
|
|
|
|
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 DEAD_STATUSES = ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken']
|
|
|
|
const [clients, skippedCount, naClients] = await Promise.all([
|
|
prisma.client.findMany({
|
|
where: {
|
|
designation: { name: { in: ['Shape', 'Shape 2'] } },
|
|
policies: { some: { status: { notIn: DEAD_STATUSES } } },
|
|
setupNaAt: null,
|
|
OR: [
|
|
{ claimsAdvocateId: null },
|
|
{ setupCompletedAt: null },
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
createdAt: true,
|
|
claimsAdvocateId: true,
|
|
setupCompletedAt: true,
|
|
policies: {
|
|
where: { status: { notIn: DEAD_STATUSES } },
|
|
select: { expirationDate: true },
|
|
},
|
|
_count: { select: { policies: { where: { status: { notIn: DEAD_STATUSES } } } } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
}),
|
|
prisma.client.count({
|
|
where: {
|
|
designation: { name: { in: ['Shape', 'Shape 2'] } },
|
|
setupNaAt: null,
|
|
OR: [{ claimsAdvocateId: null }, { setupCompletedAt: null }],
|
|
NOT: { policies: { some: { status: { notIn: DEAD_STATUSES } } } },
|
|
},
|
|
}),
|
|
prisma.client.findMany({
|
|
where: {
|
|
designation: { name: { in: ['Shape', 'Shape 2'] } },
|
|
setupNaAt: { not: null },
|
|
OR: [{ claimsAdvocateId: null }, { setupCompletedAt: null }],
|
|
},
|
|
select: { id: true, name: true, setupNaAt: true, setupNaReason: true },
|
|
orderBy: { setupNaAt: 'desc' },
|
|
}),
|
|
])
|
|
|
|
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.
|
|
{skippedCount > 0 && (
|
|
<span className="ml-2 text-xs text-muted-foreground/70">
|
|
({skippedCount} skipped — no active policies)
|
|
</span>
|
|
)}
|
|
</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">
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<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>
|
|
<SetupNaButton clientId={row.id} clientName={row.name} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{naClients.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Marked N/A ({naClients.length})</CardTitle>
|
|
<CardDescription>
|
|
Excluded from the setup queue. Restore to put a client back.
|
|
</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-left font-medium text-muted-foreground">Reason</th>
|
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Marked</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{naClients.map((c) => (
|
|
<tr key={c.id} className="hover:bg-muted/30 transition-colors">
|
|
<td className="px-4 py-3 font-medium">
|
|
<Link href={`/clients/${c.id}`} className="hover:underline text-primary">
|
|
{c.name}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{c.setupNaReason ?? '—'}
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground" suppressHydrationWarning>
|
|
{formatDate(c.setupNaAt)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<SetupNaUndoButton clientId={c.id} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|