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)
This commit is contained in:
lorentz 2026-07-16 22:32:04 +00:00
parent 30fe43fbf6
commit 6dd7e3e836
23 changed files with 709 additions and 81 deletions

View file

@ -132,6 +132,8 @@ model Client {
customFields Json @default("{}") @map("custom_fields")
renewalDate DateTime? @map("renewal_date")
setupCompletedAt DateTime? @map("setup_completed_at")
setupNaAt DateTime? @map("setup_na_at")
setupNaReason String? @map("setup_na_reason")
lastSyncedAt DateTime? @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { formatDateTime } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@ -176,8 +177,8 @@ export function AuditLogClient({ users }: AuditLogClientProps) {
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap" suppressHydrationWarning>
{new Date(log.createdAt).toLocaleString()}
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(log.createdAt)}
</TableCell>
<TableCell>
<Badge variant={actionColor(log.action)}>{log.action}</Badge>

View file

@ -16,6 +16,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Download, Trash2, Play, RefreshCw, Database, Clock, FileArchive, ScrollText } from 'lucide-react'
import { formatDateTime } from '@/lib/utils'
interface BackupFile {
filename: string
@ -35,10 +36,7 @@ function formatBytes(bytes: number) {
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
})
return formatDateTime(iso)
}
export function BackupsClient() {

View file

@ -27,6 +27,7 @@ export default async function ManagerPage() {
where: {
designation: { name: { in: ['Shape', 'Shape 2'] } },
policies: { some: {} },
setupNaAt: null,
OR: [
{ claimsAdvocateId: null },
{ setupCompletedAt: null },

View file

@ -6,12 +6,14 @@ 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 d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
return formatDateEst(d)
}
function addDay(d: Date | null): Date | null {
@ -32,11 +34,12 @@ export default async function SetupQueuePage() {
const DEAD_STATUSES = ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken']
const [clients, skippedCount] = await Promise.all([
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 },
@ -59,10 +62,20 @@ export default async function SetupQueuePage() {
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()
@ -183,12 +196,15 @@ export default async function SetupQueuePage() {
{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>
<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>
)
@ -199,6 +215,51 @@ export default async function SetupQueuePage() {
</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>
)
}

View file

@ -24,7 +24,7 @@ import { UserSelectContent } from '@/components/ui/user-select-content'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter, Pencil, ChevronDown, Info } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Input } from '@/components/ui/input'
import { formatDate, formatRenewalDate } from '@/lib/utils'
import { formatDate, formatRenewalDate, formatDateTime, todayInAppTimeZone } from '@/lib/utils'
import Link from 'next/link'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
@ -136,7 +136,7 @@ function TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivi
if (!isCompleted) {
setImageRightFiled(null)
setReminderDate('')
setCompletionDate(new Date().toISOString().split('T')[0])
setCompletionDate(todayInAppTimeZone())
setCompleteDialogOpen(true)
return
}
@ -477,7 +477,7 @@ function TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivi
type="date"
value={completionDate}
onChange={(e) => setCompletionDate(e.target.value)}
max={new Date().toISOString().split('T')[0]}
max={todayInAppTimeZone()}
className="w-48"
/>
</div>
@ -506,7 +506,7 @@ function TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivi
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
min={todayInAppTimeZone()}
className="w-48"
/>
</div>
@ -566,8 +566,8 @@ function TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivi
<span className="font-medium text-xs">
{n.user.displayName || n.user.email}
</span>
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
{new Date(n.createdAt).toLocaleString()}
<span className="text-xs text-muted-foreground">
{formatDateTime(n.createdAt)}
</span>
</div>
<p className="whitespace-pre-wrap text-foreground/90">{n.content}</p>

View file

@ -59,7 +59,8 @@ export default async function TasksPage() {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true, carrierName: true, writingCompanyName: true } },
policyGroup: { select: { id: true, name: true, renewalDate: true } },
template: { select: { id: true, name: true, level: true } },
template: { select: { id: true, name: true, level: true, daysOffset: true } },
creator: { select: { displayName: true, email: true } },
assignments: {
include: { user: { select: { displayName: true, email: true } } },
},

View file

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/clients/[id]/setup-na
* Mark a client as N/A for setup (e.g. not a commercial Shape account, or lost
* business that was never removed). Removes it from the setup queue.
* Body: { reason?: string }
*
* DELETE /api/clients/[id]/setup-na
* Undo puts the client back in the setup queue.
*
* Requires Admin or Manager role.
*/
async function authorize() {
const session = await getServerSession(authOptions)
if (!session?.user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
}
return { session }
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await authorize()
if (auth.error) return auth.error
const { id } = await params
const body = await request.json().catch(() => ({}))
const reason = typeof body.reason === 'string' && body.reason.trim() !== '' ? body.reason.trim() : null
const client = await prisma.client.findUnique({ where: { id }, select: { id: true } })
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
const updated = await prisma.client.update({
where: { id },
data: { setupNaAt: new Date(), setupNaReason: reason },
select: { id: true, setupNaAt: true, setupNaReason: true },
})
await prisma.auditLog.create({
data: {
action: 'CLIENT_SETUP_NA',
entityType: 'Client',
entityId: id,
userId: (auth.session!.user as any).id,
newValues: { reason },
},
})
return NextResponse.json(updated)
} catch (error) {
console.error('Setup N/A error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await authorize()
if (auth.error) return auth.error
const { id } = await params
const client = await prisma.client.findUnique({ where: { id }, select: { id: true } })
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
const updated = await prisma.client.update({
where: { id },
data: { setupNaAt: null, setupNaReason: null },
select: { id: true, setupNaAt: true },
})
await prisma.auditLog.create({
data: {
action: 'CLIENT_SETUP_NA_UNDONE',
entityType: 'Client',
entityId: id,
userId: (auth.session!.user as any).id,
newValues: {},
},
})
return NextResponse.json(updated)
} catch (error) {
console.error('Setup N/A undo error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -74,7 +74,10 @@ export async function GET(
select: { id: true, name: true, renewalDate: true },
},
template: {
select: { id: true, name: true, level: true },
select: { id: true, name: true, level: true, daysOffset: true },
},
creator: {
select: { displayName: true, email: true },
},
taskNotes: {
include: { user: { select: { id: true, displayName: true, email: true } } },

View file

@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
const where = {
designation: { name: { in: ['Shape', 'Shape 2'] } },
policies: { some: {} },
setupNaAt: null,
OR: [
{ claimsAdvocateId: null },
{ setupCompletedAt: null },

View file

@ -74,6 +74,7 @@ export async function GET(request: NextRequest) {
prisma.client.count({
where: {
designation: { name: { in: ['Shape', 'Shape 2'] } },
setupNaAt: null,
OR: [{ claimsAdvocateId: null }, { setupCompletedAt: null }],
},
}),

View file

@ -119,6 +119,12 @@ export async function GET(request: NextRequest) {
renewalDate: true,
},
},
template: {
select: { id: true, name: true, level: true, daysOffset: true },
},
creator: {
select: { displayName: true, email: true },
},
assignments: {
include: {
user: {

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { toast } from 'sonner'
import { formatDateTime } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -72,10 +73,7 @@ function StatLine({ label, value }: { label: string; value: number | string }) {
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit',
})
return formatDateTime(iso, { timeZoneName: undefined })
}
export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {

View file

@ -32,7 +32,7 @@ import {
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, Settings2, MessageSquare, FileSearch } from 'lucide-react'
import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
import { formatDate, formatRenewalDate, daysUntil } from '@/lib/utils'
import { formatDate, formatRenewalDate, formatDateTime, daysUntil } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { TaskCard } from '@/components/tasks/task-card'
@ -303,7 +303,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
body: JSON.stringify({ notes: notes || null }),
})
if (!res.ok) throw new Error()
setNotesSavedAt(new Date().toLocaleString())
setNotesSavedAt(formatDateTime(new Date()))
toast.success('Notes saved')
} catch {
toast.error('Failed to save notes')
@ -722,7 +722,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
policyGroups: policyGroups.map((g: any) => ({
id: g.id,
name: g.name,
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
renewalDate: g.renewalDate ? formatDate(g.renewalDate) : undefined,
})),
}}
onCreated={() => refreshTasks()}

View file

@ -678,7 +678,7 @@ export function PolicyGroupManager({
policyGroups: groups.map((g) => ({
id: g.id,
name: g.name,
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
renewalDate: g.renewalDate ? formatDate(g.renewalDate) : undefined,
})),
policies: grp?.policies.map((p) => ({
id: p.id,

View file

@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Ban, Undo2 } from 'lucide-react'
export function SetupNaButton({ clientId, clientName }: { clientId: string; clientName: string }) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('')
const [saving, setSaving] = useState(false)
async function markNa() {
setSaving(true)
try {
const res = await fetch(`/api/clients/${clientId}/setup-na`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
if (res.ok) {
setOpen(false)
router.refresh()
}
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-destructive gap-1"
>
<Ban className="h-3 w-3" /> N/A
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark setup as N/A</DialogTitle>
<DialogDescription>
Remove <span className="font-medium">{clientName}</span> from the setup queue.
Use this for accounts that are not commercial Shape accounts or lost business
that was never removed. This can be undone later.
</DialogDescription>
</DialogHeader>
<Textarea
placeholder="Reason (optional) — e.g. not a Shape commercial account, lost business"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" onClick={markNa} disabled={saving}>
{saving ? 'Saving…' : 'Mark N/A'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function SetupNaUndoButton({ clientId }: { clientId: string }) {
const router = useRouter()
const [saving, setSaving] = useState(false)
async function undo() {
setSaving(true)
try {
const res = await fetch(`/api/clients/${clientId}/setup-na`, { method: 'DELETE' })
if (res.ok) router.refresh()
} finally {
setSaving(false)
}
}
return (
<Button
variant="ghost"
size="sm"
onClick={undo}
disabled={saving}
className="text-xs text-muted-foreground hover:text-foreground gap-1"
>
<Undo2 className="h-3 w-3" /> {saving ? 'Restoring…' : 'Restore'}
</Button>
)
}

View file

@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Textarea } from '@/components/ui/textarea'
import { PolicyChip, PolicyChipData } from './policy-chip'
import { DateRule } from '@/lib/renewal-group-recommendations'
import { formatDate } from '@/lib/utils'
export interface GroupState {
id: string
@ -75,13 +76,7 @@ export function GroupCard({
}
}
const displayRenewalDate = group.renewalDate
? new Date(group.renewalDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: '—'
const displayRenewalDate = group.renewalDate ? formatDate(group.renewalDate) : '—'
return (
<Card

View file

@ -21,7 +21,7 @@ import {
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { MessageSquare, CheckCircle2, RotateCcw, Ban, Pencil, ChevronDown, X, Info } from 'lucide-react'
import { formatDate, formatRenewalDate } from '@/lib/utils'
import { formatDate, formatRenewalDate, formatDateTime, todayInAppTimeZone } from '@/lib/utils'
import Link from 'next/link'
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
@ -45,7 +45,10 @@ export interface TaskCardTask {
daysOffset?: number | null
timing?: string | null
templateId?: string | null
template?: { id?: string; name: string; level?: string } | null
template?: { id?: string; name: string; level?: string; daysOffset?: number | null } | null
createdAt?: string | Date | null
createdBy?: string | null
creator?: { displayName: string | null; email: string } | null
}
function StatusBadge({ status }: { status: string }) {
@ -77,17 +80,88 @@ function PriorityDot({ priority }: { priority: string }) {
)
}
function buildOriginLabel(task: TaskCardTask): string {
if (task.isAdHoc) return 'Ad hoc task, added manually'
if (!task.templateId) return 'Template-generated task (template details unavailable)'
const days = task.daysOffset ?? 0
const absDays = Math.abs(days)
const timing = task.timing === 'POST_RENEWAL'
? `${absDays} day${absDays !== 1 ? 's' : ''} after renewal`
: `${absDays} day${absDays !== 1 ? 's' : ''} before renewal`
const level = task.policyGroup ? 'Group level' : task.policy ? 'Policy level' : 'Client level'
const templateName = task.template?.name ?? task.title
return `${timing} · ${level} · Template: ${templateName}`
function addDays(d: string | Date, days: number): Date {
const r = new Date(d)
r.setDate(r.getDate() + days)
return r
}
function TaskOriginContent({ task }: { task: TaskCardTask }) {
const creatorName = task.creator?.displayName || task.creator?.email || null
const createdOn = task.createdAt ? formatDate(task.createdAt) : null
if (task.isAdHoc || (!task.templateId && task.createdBy)) {
const kind =
task.taskGroup === 'REMINDER' ? 'Reminder' : task.taskGroup === 'SERVICE' ? 'Additional service' : 'Ad hoc task'
return (
<div className="space-y-1">
<p className="font-semibold">{kind} created manually</p>
<p>
By <span className="font-medium">{creatorName ?? 'unknown user'}</span>
{createdOn && <> on <span suppressHydrationWarning>{createdOn}</span></>}
</p>
</div>
)
}
if (!task.templateId) {
return <p>Origin unknown no template or creator recorded.</p>
}
const level = task.policyGroup ? 'Group' : task.policy ? 'Policy' : 'Client'
const days = task.daysOffset ?? task.template?.daysOffset ?? null
const absDays = days !== null ? Math.abs(days) : null
const after = task.timing === 'POST_RENEWAL' || (days !== null && days > 0)
// Anchor date: group renewalDate, policy expiration + 1, client anchor group,
// else derive it from dueDate offset.
let anchorLabel: string | null = null
let anchorDate: Date | null = null
if (task.policyGroup?.renewalDate) {
anchorLabel = `Renewal group \u201c${task.policyGroup.name ?? 'Unnamed'}\u201d renews`
anchorDate = new Date(task.policyGroup.renewalDate)
} else if (task.policy?.expirationDate) {
anchorLabel = `Policy ${task.policy.policyNumber ?? ''} renews (expiration + 1 day)`
anchorDate = addDays(task.policy.expirationDate, 1)
} else if (task.anchorGroup?.renewalDate) {
anchorLabel = 'Client renewal (earliest renewal group)'
anchorDate = new Date(task.anchorGroup.renewalDate)
} else if (days !== null) {
anchorLabel = 'Renewal anchor (derived from due date)'
anchorDate = addDays(task.dueDate, -days)
}
return (
<div className="space-y-1">
<p className="font-semibold">Auto-generated from template</p>
<p>
<span className="text-muted-foreground">Template:</span>{' '}
<span className="font-medium">{task.template?.name ?? task.title}</span> ({level} level)
</p>
{anchorDate && (
<p>
<span className="text-muted-foreground">{anchorLabel}:</span>{' '}
<span className="font-medium" suppressHydrationWarning>{formatDate(anchorDate)}</span>
</p>
)}
{absDays !== null && (
<p>
<span className="text-muted-foreground">Due:</span>{' '}
<span className="font-medium">{absDays} day{absDays !== 1 ? 's' : ''} {after ? 'after' : 'before'} renewal</span>
{' '} <span className="font-medium" suppressHydrationWarning>{formatDate(task.dueDate)}</span>
</p>
)}
<p>
<span className="text-muted-foreground">Generated:</span>{' '}
{creatorName ? (
<>by <span className="font-medium">{creatorName}</span></>
) : (
<span className="font-medium">by system automation</span>
)}
{createdOn && <> on <span suppressHydrationWarning>{createdOn}</span></>}
</p>
</div>
)
}
interface TaskCardProps {
@ -133,7 +207,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivi
if (!isCompleted) {
setImageRightFiled(null)
setReminderDate('')
setCompletionDate(new Date().toISOString().split('T')[0])
setCompletionDate(todayInAppTimeZone())
setCompleteDialogOpen(true)
return
}
@ -426,20 +500,18 @@ export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivi
{/* Row 3: Level | Edit */}
<div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5 text-muted-foreground">
{levelLabel}
{isPrivileged && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-default">
<Info className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{buildOriginLabel(task)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-default">
<Info className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-sm text-xs">
<TaskOriginContent task={task} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<TooltipProvider>
<Tooltip>
@ -474,7 +546,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivi
<div className="space-y-4 py-2">
<div>
<p className="text-sm font-medium mb-2">Date completed</p>
<Input type="date" value={completionDate} onChange={(e) => setCompletionDate(e.target.value)} max={new Date().toISOString().split('T')[0]} className="w-48" />
<Input type="date" value={completionDate} onChange={(e) => setCompletionDate(e.target.value)} max={todayInAppTimeZone()} className="w-48" />
</div>
<div>
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
@ -485,7 +557,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivi
</div>
<div>
<p className="text-sm font-medium mb-2">Set a reminder? <span className="text-muted-foreground font-normal">(optional)</span></p>
<Input type="date" value={reminderDate} onChange={(e) => setReminderDate(e.target.value)} min={new Date().toISOString().split('T')[0]} className="w-48" />
<Input type="date" value={reminderDate} onChange={(e) => setReminderDate(e.target.value)} min={todayInAppTimeZone()} className="w-48" />
</div>
</div>
<DialogFooter>
@ -529,7 +601,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivi
<div key={n.id} className="rounded-md bg-muted/40 px-3 py-2 text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-xs">{n.user.displayName || n.user.email}</span>
<span className="text-xs text-muted-foreground" suppressHydrationWarning>{new Date(n.createdAt).toLocaleString()}</span>
<span className="text-xs text-muted-foreground">{formatDateTime(n.createdAt)}</span>
</div>
<p className="whitespace-pre-wrap text-foreground/90">{n.content}</p>
</div>

View file

@ -0,0 +1,131 @@
const mockPolicyGroupFindMany = jest.fn()
const mockPolicyFindMany = jest.fn()
const mockClientFindMany = jest.fn()
const mockTaskTemplateFindMany = jest.fn()
const mockTaskCreateMany = jest.fn().mockResolvedValue({ count: 0 })
const mockTaskFindMany = jest.fn().mockResolvedValue([])
const mockTaskAssignmentCreateMany = jest.fn().mockResolvedValue({ count: 0 })
const mockAuditLogCreate = jest.fn().mockResolvedValue({})
jest.mock('@/lib/db', () => ({
prisma: {
policyGroup: { findMany: (...args: any[]) => mockPolicyGroupFindMany(...args) },
policy: { findMany: (...args: any[]) => mockPolicyFindMany(...args) },
client: { findMany: (...args: any[]) => mockClientFindMany(...args) },
taskTemplate: { findMany: (...args: any[]) => mockTaskTemplateFindMany(...args) },
task: {
createMany: (...args: any[]) => mockTaskCreateMany(...args),
findMany: (...args: any[]) => mockTaskFindMany(...args),
},
taskAssignment: { createMany: (...args: any[]) => mockTaskAssignmentCreateMany(...args) },
auditLog: { create: (...args: any[]) => mockAuditLogCreate(...args) },
},
}))
import { runAutoGenerate } from '../auto-generate'
import { AUTOMATION_GO_LIVE_AT, type AutomationConfig } from '../automation-config'
const now = new Date('2026-07-09T00:00:00Z')
const config: AutomationConfig = {
taskAutogenEnabled: true,
taskGracePolicyDays: 20,
taskGraceClientDays: 20,
groupAutoassignEnabled: true,
groupAutoassignWindowDays: 35,
}
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers().setSystemTime(now)
mockPolicyGroupFindMany.mockResolvedValue([])
mockPolicyFindMany.mockResolvedValue([])
mockClientFindMany.mockResolvedValue([])
mockTaskTemplateFindMany.mockResolvedValue([])
mockTaskCreateMany.mockResolvedValue({ count: 0 })
mockTaskFindMany.mockResolvedValue([])
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 0 })
})
afterEach(() => {
jest.useRealTimers()
})
describe('runAutoGenerate — CLIENT-level task generation', () => {
it('gates on the anchor (policyGroup/policy) createdAt, not the Client.createdAt', async () => {
await runAutoGenerate(config)
expect(mockClientFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{
policyGroups: {
some: {
createdAt: {
gte: AUTOMATION_GO_LIVE_AT,
lte: new Date(now.getTime() - config.taskGraceClientDays * 24 * 60 * 60 * 1000),
},
},
},
},
{
policies: {
some: {
policyGroupId: null,
createdAt: {
gte: AUTOMATION_GO_LIVE_AT,
lte: new Date(now.getTime() - config.taskGraceClientDays * 24 * 60 * 60 * 1000),
},
},
},
},
],
}),
})
)
// The old (buggy) gate on the Client record's own createdAt must be gone.
const callArgs = mockClientFindMany.mock.calls[0][0]
expect(callArgs.where.createdAt).toBeUndefined()
})
it('creates CLIENT-level tasks for a client that predates go-live but has a fresh post-go-live policy group', async () => {
const client = {
id: 'client-sultan',
designationId: null,
designation2Id: null,
claimsAdvocateId: 'advocate-1',
policyGroups: [{ renewalDate: new Date('2027-07-08') }],
policies: [],
}
mockClientFindMany.mockResolvedValue([client])
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-1',
name: 'Claim Review',
description: null,
department: 'Claims',
timing: 'PRE_RENEWAL',
daysOffset: -90,
defaultPriority: 'NORMAL',
level: 'CLIENT',
},
])
mockTaskCreateMany.mockResolvedValue({ count: 1 })
const result = await runAutoGenerate(config)
expect(mockTaskCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [
expect.objectContaining({
clientId: 'client-sultan',
templateId: 'template-1',
}),
],
})
)
expect(result.clientTasksCreated).toBe(1)
})
})

View file

@ -0,0 +1,76 @@
import { prisma } from '@/lib/db'
export interface AutoAssignTasksResult {
assigned: number
considered: number
errors: string[]
}
/**
* Assign any unassigned, non-ad-hoc policy- or group-level tasks to the
* client's claims advocate. This is intended to run as part of the nightly
* post-sync automation so that tasks created manually during the business day
* are assigned by the next sync.
*/
export async function runAutoAssignTasks(): Promise<AutoAssignTasksResult> {
const errors: string[] = []
const openStatuses = ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] as any[]
const tasks = await prisma.task.findMany({
where: {
isAdHoc: false,
status: { in: openStatuses },
assignments: { none: {} },
OR: [{ policyId: { not: null } }, { policyGroupId: { not: null } }],
client: { claimsAdvocateId: { not: null } },
},
select: {
id: true,
client: {
select: { claimsAdvocateId: true },
},
},
})
const byAdvocate = new Map<string, string[]>()
for (const t of tasks) {
const advocateId = t.client.claimsAdvocateId
if (!advocateId) continue
const list = byAdvocate.get(advocateId) ?? []
list.push(t.id)
byAdvocate.set(advocateId, list)
}
let assigned = 0
for (const [advocateId, taskIds] of byAdvocate) {
try {
const result = await prisma.taskAssignment.createMany({
data: taskIds.map((taskId) => ({ taskId, userId: advocateId })),
skipDuplicates: true,
})
assigned += result.count
} catch (err: any) {
errors.push(`Advocate ${advocateId}: ${err.message}`)
}
}
if (tasks.length > 0) {
await prisma.auditLog
.create({
data: {
action: 'AUTO_ASSIGN_TASKS',
entityType: 'Task',
newValues: {
assigned,
considered: tasks.length,
remaining: tasks.length - assigned,
},
},
})
.catch(() => {
// Audit log failures should not fail the automation.
})
}
return { assigned, considered: tasks.length, errors }
}

View file

@ -222,15 +222,31 @@ export async function runAutoGenerate(config: AutomationConfig): Promise<AutoGen
}
// ─── 3. CLIENT-level tasks (once per client) ──────────────────────────────
// Clients aged past the client grace period that have at least one renewal
// anchor (a group or an ungrouped policy) and no CLIENT-level template tasks
// yet. New CLIENT templates added after a client is processed are applied via
// the manual "Generate & Assign" admin tool, not this auto path.
// Clients with at least one renewal anchor (a group or an ungrouped policy)
// that itself was created after go-live and has aged past the client grace
// period, and that have no CLIENT-level template tasks yet.
//
// Gated on the anchor record's own createdAt (mirroring the group/policy
// branches above), not the Client's createdAt. A client can predate go-live
// while still getting a brand-new policy group/policy afterward (e.g. a
// pre-existing client whose first renewal cycle starts post-go-live) — that
// anchor is legitimately new and must not be skipped just because the client
// shell is old. New CLIENT templates added after a client is processed are
// applied via the manual "Generate & Assign" admin tool, not this auto path.
const clients = await prisma.client.findMany({
where: {
createdAt: { gte: AUTOMATION_GO_LIVE_AT, lte: clientCutoff },
tasks: { none: { templateId: { not: null }, policyId: null, policyGroupId: null } },
OR: [{ policyGroups: { some: {} } }, { policies: { some: { policyGroupId: null } } }],
OR: [
{ policyGroups: { some: { createdAt: { gte: AUTOMATION_GO_LIVE_AT, lte: clientCutoff } } } },
{
policies: {
some: {
policyGroupId: null,
createdAt: { gte: AUTOMATION_GO_LIVE_AT, lte: clientCutoff },
},
},
},
],
},
select: {
id: true,

View file

@ -15,6 +15,7 @@ import { sleep } from '@/lib/utils'
import { getAutomationConfig } from './automation-config'
import { runGroupAutoAssign } from './group-auto-assign'
import { runAutoGenerate } from './auto-generate'
import { runAutoAssignTasks } from './auto-assign-tasks'
export interface SyncResult {
success: boolean
@ -221,6 +222,12 @@ async function runPostSyncAutomation(): Promise<void> {
)
if (gen.errors.length > 0) console.warn('⚠️ Task auto-generate errors:', gen.errors)
}
const autoAssign = await runAutoAssignTasks()
console.log(
`📝 Task auto-assign: ${autoAssign.assigned} of ${autoAssign.considered} unassigned policy/group tasks assigned`
)
if (autoAssign.errors.length > 0) console.warn('⚠️ Task auto-assign errors:', autoAssign.errors)
} catch (error) {
console.error('❌ Post-sync automation failed (sync itself succeeded):', error)
} finally {

View file

@ -6,22 +6,52 @@ export function cn(...inputs: ClassValue[]) {
}
/**
* Format a date to a localized string
* All users are currently in EST/EDT. Every date/time displayed in the app
* must render in this timezone, regardless of the server's or browser's
* local system timezone hardcoding it here also keeps SSR and client
* hydration in sync (no more `suppressHydrationWarning` drift).
*/
export const APP_TIME_ZONE = 'America/New_York'
/**
* Format a date to a localized string, always in APP_TIME_ZONE.
*/
export function formatDate(date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
if (!date) return ''
const dateObj = typeof date === 'string'
? new Date(date.includes('T') ? date : date + 'T00:00:00')
: date
return new Intl.DateTimeFormat('en-US', options || {
year: 'numeric',
month: 'short',
day: 'numeric'
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: APP_TIME_ZONE,
...options,
}).format(dateObj)
}
/**
* Format a policy expiration date as the renewal date (expiration + 1 day).
* Format a date+time to a localized string, always in APP_TIME_ZONE.
* Use for timestamps such as createdAt, completedAt, audit log entries, etc.
*/
export function formatDateTime(date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
if (!date) return ''
const dateObj = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone: APP_TIME_ZONE,
...options,
}).format(dateObj)
}
/**
* Format a policy expiration date as the renewal date (expiration + 1 day),
* always in APP_TIME_ZONE.
* AMS stores expiration date (e.g. 12/31/2026); the renewal date is 1/1/2027.
*/
export function formatRenewalDate(expirationDate: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
@ -30,13 +60,30 @@ export function formatRenewalDate(expirationDate: Date | string | null | undefin
? new Date(expirationDate.includes('T') ? expirationDate : expirationDate + 'T00:00:00')
: new Date(expirationDate)
d.setDate(d.getDate() + 1)
return new Intl.DateTimeFormat('en-US', options || {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: APP_TIME_ZONE,
...options,
}).format(d)
}
/**
* Today's calendar date (YYYY-MM-DD) in APP_TIME_ZONE. Use this instead of
* `new Date().toISOString().split('T')[0]` for "today" defaults/min/max on
* date inputs the ISO/UTC version rolls over to the next day starting at
* 7-8pm Eastern, which is still "today" for our users.
*/
export function todayInAppTimeZone(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: APP_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date())
}
/**
* Format a date to relative time (e.g., "2 days ago", "in 3 hours")
*/