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:
parent
30fe43fbf6
commit
6dd7e3e836
23 changed files with 709 additions and 81 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
},
|
||||
|
|
|
|||
106
ondeck/src/app/api/clients/[id]/setup-na/route.ts
Normal file
106
ondeck/src/app/api/clients/[id]/setup-na/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } } },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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[] }) {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
105
ondeck/src/components/clients/setup-na-button.tsx
Normal file
105
ondeck/src/components/clients/setup-na-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
131
ondeck/src/lib/sync/__tests__/auto-generate.test.ts
Normal file
131
ondeck/src/lib/sync/__tests__/auto-generate.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
76
ondeck/src/lib/sync/auto-assign-tasks.ts
Normal file
76
ondeck/src/lib/sync/auto-assign-tasks.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 || {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
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")
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue