From 6dd7e3e83628683f35e1de35207ca7aab9c67ee7 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 22:32:04 +0000 Subject: [PATCH] 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) --- ondeck/prisma/schema.prisma | 2 + .../(dashboard)/admin/audit/page-client.tsx | 5 +- .../(dashboard)/admin/backups/page-client.tsx | 6 +- ondeck/src/app/(dashboard)/manager/page.tsx | 1 + .../app/(dashboard)/manager/setup/page.tsx | 77 ++++++++-- .../src/app/(dashboard)/tasks/page-client.tsx | 12 +- ondeck/src/app/(dashboard)/tasks/page.tsx | 3 +- .../app/api/clients/[id]/setup-na/route.ts | 106 ++++++++++++++ .../src/app/api/clients/[id]/tasks/route.ts | 5 +- .../src/app/api/clients/setup-queue/route.ts | 1 + ondeck/src/app/api/metrics/route.ts | 1 + ondeck/src/app/api/tasks/route.ts | 6 + .../components/admin/shape-import-panel.tsx | 6 +- .../src/components/clients/client-detail.tsx | 6 +- .../clients/policy-group-manager.tsx | 2 +- .../components/clients/setup-na-button.tsx | 105 ++++++++++++++ .../components/renewal-groups/group-card.tsx | 9 +- ondeck/src/components/tasks/task-card.tsx | 134 ++++++++++++++---- .../lib/sync/__tests__/auto-generate.test.ts | 131 +++++++++++++++++ ondeck/src/lib/sync/auto-assign-tasks.ts | 76 ++++++++++ ondeck/src/lib/sync/auto-generate.ts | 28 +++- ondeck/src/lib/sync/sync-engine.ts | 7 + ondeck/src/lib/utils.ts | 61 +++++++- 23 files changed, 709 insertions(+), 81 deletions(-) create mode 100644 ondeck/src/app/api/clients/[id]/setup-na/route.ts create mode 100644 ondeck/src/components/clients/setup-na-button.tsx create mode 100644 ondeck/src/lib/sync/__tests__/auto-generate.test.ts create mode 100644 ondeck/src/lib/sync/auto-assign-tasks.ts diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma index 41a3505..5cae5bb 100644 --- a/ondeck/prisma/schema.prisma +++ b/ondeck/prisma/schema.prisma @@ -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") diff --git a/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx b/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx index b7b5025..7d5fdd5 100644 --- a/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx +++ b/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx @@ -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) { {logs.map((log) => ( - - {new Date(log.createdAt).toLocaleString()} + + {formatDateTime(log.createdAt)} {log.action} diff --git a/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx b/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx index f1020cd..d5c06f8 100644 --- a/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx +++ b/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx @@ -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() { diff --git a/ondeck/src/app/(dashboard)/manager/page.tsx b/ondeck/src/app/(dashboard)/manager/page.tsx index 87088d9..3c04097 100644 --- a/ondeck/src/app/(dashboard)/manager/page.tsx +++ b/ondeck/src/app/(dashboard)/manager/page.tsx @@ -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 }, diff --git a/ondeck/src/app/(dashboard)/manager/setup/page.tsx b/ondeck/src/app/(dashboard)/manager/setup/page.tsx index d18d618..2607835 100644 --- a/ondeck/src/app/(dashboard)/manager/setup/page.tsx +++ b/ondeck/src/app/(dashboard)/manager/setup/page.tsx @@ -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 - - Configure - +
+ + Configure + + +
) @@ -199,6 +215,51 @@ export default async function SetupQueuePage() { )} + + {naClients.length > 0 && ( + + + Marked N/A ({naClients.length}) + + Excluded from the setup queue. Restore to put a client back. + + + +
+ + + + + + + + + + {naClients.map((c) => ( + + + + + + + ))} + +
ClientReasonMarked +
+ + {c.name} + + + {c.setupNaReason ?? '—'} + + {formatDate(c.setupNaAt)} + + +
+
+
+
+ )} ) } diff --git a/ondeck/src/app/(dashboard)/tasks/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/page-client.tsx index fc3bdcb..5892990 100644 --- a/ondeck/src/app/(dashboard)/tasks/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page-client.tsx @@ -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" /> @@ -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" /> @@ -566,8 +566,8 @@ function TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivi {n.user.displayName || n.user.email} - - {new Date(n.createdAt).toLocaleString()} + + {formatDateTime(n.createdAt)}

{n.content}

diff --git a/ondeck/src/app/(dashboard)/tasks/page.tsx b/ondeck/src/app/(dashboard)/tasks/page.tsx index 6176d1d..98b33e4 100644 --- a/ondeck/src/app/(dashboard)/tasks/page.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page.tsx @@ -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 } } }, }, diff --git a/ondeck/src/app/api/clients/[id]/setup-na/route.ts b/ondeck/src/app/api/clients/[id]/setup-na/route.ts new file mode 100644 index 0000000..62267af --- /dev/null +++ b/ondeck/src/app/api/clients/[id]/setup-na/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/clients/[id]/tasks/route.ts b/ondeck/src/app/api/clients/[id]/tasks/route.ts index 5d8460a..8623cd0 100644 --- a/ondeck/src/app/api/clients/[id]/tasks/route.ts +++ b/ondeck/src/app/api/clients/[id]/tasks/route.ts @@ -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 } } }, diff --git a/ondeck/src/app/api/clients/setup-queue/route.ts b/ondeck/src/app/api/clients/setup-queue/route.ts index 85ef3b0..cdd279b 100644 --- a/ondeck/src/app/api/clients/setup-queue/route.ts +++ b/ondeck/src/app/api/clients/setup-queue/route.ts @@ -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 }, diff --git a/ondeck/src/app/api/metrics/route.ts b/ondeck/src/app/api/metrics/route.ts index a726658..cbbccdb 100644 --- a/ondeck/src/app/api/metrics/route.ts +++ b/ondeck/src/app/api/metrics/route.ts @@ -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 }], }, }), diff --git a/ondeck/src/app/api/tasks/route.ts b/ondeck/src/app/api/tasks/route.ts index 87922b5..1753bca 100644 --- a/ondeck/src/app/api/tasks/route.ts +++ b/ondeck/src/app/api/tasks/route.ts @@ -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: { diff --git a/ondeck/src/components/admin/shape-import-panel.tsx b/ondeck/src/components/admin/shape-import-panel.tsx index 8ea9366..f8d1f3c 100644 --- a/ondeck/src/components/admin/shape-import-panel.tsx +++ b/ondeck/src/components/admin/shape-import-panel.tsx @@ -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[] }) { diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index e038bfd..4459dce 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -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()} diff --git a/ondeck/src/components/clients/policy-group-manager.tsx b/ondeck/src/components/clients/policy-group-manager.tsx index 2d2eceb..207059c 100644 --- a/ondeck/src/components/clients/policy-group-manager.tsx +++ b/ondeck/src/components/clients/policy-group-manager.tsx @@ -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, diff --git a/ondeck/src/components/clients/setup-na-button.tsx b/ondeck/src/components/clients/setup-na-button.tsx new file mode 100644 index 0000000..b298828 --- /dev/null +++ b/ondeck/src/components/clients/setup-na-button.tsx @@ -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 ( + + + + + + + Mark setup as N/A + + Remove {clientName} 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. + + +