seubert-claims/ondeck/src/app/(dashboard)/manager/setup/page.tsx
lorentz 6dd7e3e836 fix(tasks): timezone consistency, task provenance, setup N/A, and task-generation fixes
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)
2026-07-16 22:32:04 +00:00

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>
)
}