Fix: open overdue tasks no longer hidden by 7-day age cutoff

RCA: the dueDate >= cutoff filter was incorrectly excluding open tasks
more than 7 days overdue. The cutoff now only applies to terminal tasks
(COMPLETED/CANCELLED/NA) to suppress ancient clutter. Open tasks of any
age are always returned.

Fixes tasks/page.tsx and clients/[id]/page.tsx.
This commit is contained in:
lorentz 2026-05-19 03:24:14 +00:00
parent e6485b35dc
commit a4dfa0db59
16 changed files with 247 additions and 28 deletions

101
ondeck/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,101 @@
# Horizon — Architecture
## Overview
Next.js 15 App Router application. All pages are server or client components under `src/app/(dashboard)/`. Data access goes exclusively through Next.js API route handlers (`src/app/api/`), which use Prisma to talk to Postgres.
## Directory map
```
src/
├── app/
│ ├── (dashboard)/ # Authenticated layout + all pages
│ │ ├── layout.tsx # Shell: sidebar, nav, session guard
│ │ ├── dashboard/ # Home/summary page
│ │ ├── clients/ # Client list + detail ([id]/)
│ │ ├── policies/ # Policy list + detail ([id]/)
│ │ ├── tasks/ # Task list (page-client.tsx) + detail ([id]/)
│ │ ├── admin/ # Admin-only pages (users, roles, designations, sync)
│ │ └── manager/ # Manager pages (templates, metrics)
│ ├── api/ # API route handlers
│ │ ├── auth/ # NextAuth endpoints
│ │ ├── clients/ # CRUD + notes, contacts, members
│ │ ├── tasks/ # CRUD + notes, assignments
│ │ ├── policies/ # CRUD + policy groups
│ │ ├── policy-groups/ # Renewal group management
│ │ ├── sync/ # Shape import sync
│ │ ├── users/ # User management
│ │ ├── roles/ # Role management
│ │ ├── templates/ # Task templates
│ │ ├── designations/ # Client designations
│ │ ├── metrics/ # Dashboard metrics
│ │ ├── cron/ # Scheduled jobs (notifications, etc.)
│ │ └── dashboard/ # Dashboard summary data
│ ├── auth/ # Sign-in / error pages (unauthenticated)
│ └── layout.tsx # Root layout (providers, theme)
├── components/
│ ├── ui/ # shadcn/ui primitives (Button, Dialog, Input, etc.)
│ ├── clients/ # Client-specific components
│ ├── tasks/ # Task card, task edit modal
│ ├── policies/ # Policy components
│ ├── renewal-groups/ # Policy group components
│ ├── notifications/ # Notification bell + panel
│ ├── admin/ # Admin UI components
│ ├── manager/ # Manager UI components
│ ├── layout/ # Sidebar, header, nav
│ └── providers/ # SessionProvider, ThemeProvider
├── lib/
│ ├── auth.ts # NextAuth config (Entra ID + local credentials)
│ ├── db.ts # Prisma client singleton
│ ├── entra.ts # Microsoft Graph API helpers
│ ├── utils.ts # Shared utilities (cn(), formatters)
│ ├── hooks/ # Custom React hooks
│ ├── sync/ # Shape import sync logic
│ ├── shape-import/ # Shape file parsing
│ └── tasks/ # Task business logic helpers
├── middleware.ts # Auth middleware (protects all dashboard routes)
└── types/ # Shared TypeScript types
```
## Data models (key entities)
| Model | Description |
|-------|-------------|
| `User` | Staff accounts; synced from Entra ID |
| `Role` / `UserRole` | RBAC roles (Admin, Manager, AE, Claims, etc.) |
| `Client` | Insurance clients; synced from AMS via Shape import |
| `ClientContact` | Contacts per client |
| `ClientMember` | Which staff are assigned to a client |
| `Designation` | Client classification labels (color-coded) |
| `Policy` | Insurance policies linked to clients |
| `PolicyGroup` | Renewal groups grouping policies |
| `Task` | Work items linked to client/policy/policy group |
| `TaskAssignment` | Which users are assigned to a task |
| `TaskNote` | Notes on tasks with status snapshots |
| `TaskTemplate` | Reusable task templates for automation |
| `SyncLog` | History of Shape import runs |
| `AuditLog` | Field-level change tracking |
| `Notification` | In-app notifications per user |
| `ShapeImportRun` | Shape file import run tracking |
## Auth flow
1. `middleware.ts` checks NextAuth session on every request under `/(dashboard)`.
2. Unauthenticated users are redirected to `/auth/signin`.
3. Production: Microsoft Entra ID OAuth. Entra group memberships map to Horizon roles via `EntraGroupRoleMapping`.
4. Dev: local credentials provider (see `src/lib/auth.ts`).
## Task completion flow
- UI: `src/app/(dashboard)/tasks/page-client.tsx` (task detail page) and `src/components/tasks/task-card.tsx` (list card)
- Dialog collects: completion date, ImageRight filing confirmation, optional reminder date
- `PATCH /api/tasks/[id]` sets `completedAt`, `completedBy`, `imageRightFiled`, `reminderDate`
## Sync / data import
- Client and policy data synced from Zywave/Applied AMS via Shape file import
- Shape import logic: `src/lib/shape-import/` and `src/lib/sync/`
- Triggered manually from Admin UI or via scheduled cron (`src/app/api/cron/`)
- Sync history tracked in `SyncLog` and `AuditLog`
## Deployment
- Production: Docker Compose at `/opt/stacks/horizon`
- Image built from this directory's `Dockerfile` (Next.js standalone output)
- DB: Postgres 17 in `horizon-db` container
- Reverse proxy: Pangolin → https://horizon.seubert.cloud

View file

@ -69,9 +69,15 @@ export default async function ClientDetailPage({
], ],
AND: [ AND: [
{ {
// Keep ALL open tasks regardless of age; only hide ancient terminal tasks
OR: [ OR: [
{ dueDate: { gte: cutoff } }, { status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] } },
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } }, {
AND: [
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } },
{ dueDate: { gte: cutoff } },
],
},
], ],
}, },
], ],

View file

@ -107,6 +107,7 @@ function TaskCard({ task: initial }: { task: Task }) {
const [completeDialogOpen, setCompleteDialogOpen] = useState(false) const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null) const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
const [reminderDate, setReminderDate] = useState('') const [reminderDate, setReminderDate] = useState('')
const [completionDate, setCompletionDate] = useState('')
const [completeSubmitting, setCompleteSubmitting] = useState(false) const [completeSubmitting, setCompleteSubmitting] = useState(false)
const now = new Date() const now = new Date()
@ -117,6 +118,7 @@ function TaskCard({ task: initial }: { task: Task }) {
if (!isCompleted) { if (!isCompleted) {
setImageRightFiled(null) setImageRightFiled(null)
setReminderDate('') setReminderDate('')
setCompletionDate(new Date().toISOString().split('T')[0])
setCompleteDialogOpen(true) setCompleteDialogOpen(true)
return return
} }
@ -144,6 +146,7 @@ function TaskCard({ task: initial }: { task: Task }) {
const body: any = { status: 'COMPLETED' } const body: any = { status: 'COMPLETED' }
if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled
if (reminderDate) body.reminderDate = reminderDate if (reminderDate) body.reminderDate = reminderDate
if (completionDate) body.completedAt = completionDate
const res = await fetch(`/api/tasks/${task.id}`, { const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -436,6 +439,16 @@ function TaskCard({ task: initial }: { task: Task }) {
<DialogTitle>Complete Task</DialogTitle> <DialogTitle>Complete Task</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <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"
/>
</div>
<div> <div>
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p> <p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -36,11 +36,16 @@ export default async function TasksPage() {
{ policyGroup: { renewalDate: { gte: cutoff } } }, { policyGroup: { renewalDate: { gte: cutoff } } },
], ],
}, },
// Only show recent or terminal tasks // Keep ALL open tasks regardless of age; only hide ancient terminal tasks
{ {
OR: [ OR: [
{ dueDate: { gte: cutoff } }, { status: { notIn: TERMINAL_STATUSES } },
{ status: { in: TERMINAL_STATUSES } }, {
AND: [
{ status: { in: TERMINAL_STATUSES } },
{ dueDate: { gte: cutoff } },
],
},
], ],
}, },
], ],

View file

@ -62,9 +62,9 @@ export async function GET(
return NextResponse.json({ error: 'Client not found' }, { status: 404 }) return NextResponse.json({ error: 'Client not found' }, { status: 404 })
} }
// RBAC check - non-managers can only view assigned clients // RBAC check - non-managers can only view assigned clients (Claims sees all)
const userRoles = (session.user as any).roles || [] const userRoles = (session.user as any).roles || []
const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') || userRoles.includes('Claims')
if (!isManagerOrAdmin) { if (!isManagerOrAdmin) {
// Check if user is assigned via policy personnel (exec or CSR) // Check if user is assigned via policy personnel (exec or CSR)

View file

@ -95,9 +95,9 @@ export async function GET(request: NextRequest) {
] ]
} }
// RBAC filtering - non-managers see only assigned clients // RBAC filtering - non-managers see only assigned clients (Claims sees all)
const userRoles = (session.user as any).roles || [] const userRoles = (session.user as any).roles || []
const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') || userRoles.includes('Claims')
if (!isManagerOrAdmin) { if (!isManagerOrAdmin) {
const rbacFilter = { const rbacFilter = {

View file

@ -77,14 +77,17 @@ export async function PATCH(
} }
const userRoles = (session.user as any).roles || [] const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) { const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager')
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params const { id } = await params
const body = await request.json() const body = await request.json()
const { name, renewalDate, notes, policyIds } = body const { name, renewalDate, notes, policyIds } = body
// Only admins/managers can make structural changes; anyone can save notes
if (!isManagerOrAdmin && (name !== undefined || renewalDate !== undefined || policyIds !== undefined)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const existing = await prisma.policyGroup.findUnique({ const existing = await prisma.policyGroup.findUnique({
where: { id }, where: { id },
include: { policies: { select: { id: true } } }, include: { policies: { select: { id: true } } },

View file

@ -39,7 +39,7 @@ export async function PATCH(
const body = await request.json() const body = await request.json()
const { const {
status, notes, naReason, imageRightFiled, reminderDate, status, notes, naReason, imageRightFiled, reminderDate, completedAt,
title, description, priority, department, dueDate, title, description, priority, department, dueDate,
clientId, policyId, policyGroupId, assignedUserIds, clientId, policyId, policyGroupId, assignedUserIds,
} = body } = body
@ -59,7 +59,7 @@ export async function PATCH(
if (status !== undefined) { if (status !== undefined) {
updateData.status = status updateData.status = status
if (status === 'COMPLETED') { if (status === 'COMPLETED') {
updateData.completedAt = new Date() updateData.completedAt = completedAt ? new Date(completedAt) : new Date()
updateData.completedBy = userId updateData.completedBy = userId
} else { } else {
updateData.completedAt = null updateData.completedAt = null

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { isActive: true, department: { equals: 'Claims', mode: 'insensitive' } },
orderBy: { displayName: 'asc' },
select: { id: true, displayName: true, email: true },
})
return NextResponse.json({ users })
} catch (error) {
console.error('Claims staff API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { isActive: true },
orderBy: { displayName: 'asc' },
select: { id: true, displayName: true, email: true, department: true },
})
return NextResponse.json({ users })
} catch (error) {
console.error('Staff API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -11,11 +11,14 @@ export default function SignInPage() {
<img src="/seubert-logo-black.png" alt="Seubert" width={220} style={{ filter: 'brightness(0) invert(1)' }} /> <img src="/seubert-logo-black.png" alt="Seubert" width={220} style={{ filter: 'brightness(0) invert(1)' }} />
</div> </div>
<div> <div>
<h2 className="text-4xl font-bold text-white leading-tight"> <h2 className="text-5xl font-bold text-white leading-tight tracking-tight">
Shape Process Horizon
</h2> </h2>
<p className="mt-4 text-white/70 text-lg"> <p className="mt-1 text-white/60 text-base font-medium uppercase tracking-widest">
Horizon keeps your team on top of every renewal, every client, every deadline. Operations Hub
</p>
<p className="mt-6 text-white/70 text-lg leading-relaxed">
Stay on top of every renewal, every client, every deadline.
</p> </p>
</div> </div>
<p className="text-white/40 text-sm"> <p className="text-white/40 text-sm">

View file

@ -115,11 +115,11 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
const [addMemberId, setAddMemberId] = useState<string>('') const [addMemberId, setAddMemberId] = useState<string>('')
useEffect(() => { useEffect(() => {
fetch('/api/users?isActive=true&limit=200') fetch('/api/users/staff')
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setAllUsers(d.users || [])) .then((d) => setAllUsers(d.users || []))
.catch(() => {}) .catch(() => {})
fetch('/api/users?isActive=true&limit=200&department=Claims') fetch('/api/users/claims-staff')
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setClaimsUsers(d.users || [])) .then((d) => setClaimsUsers(d.users || []))
.catch(() => {}) .catch(() => {})

View file

@ -86,7 +86,7 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
} }
useEffect(() => { useEffect(() => {
fetch('/api/users?isActive=true&limit=200&fields=department') fetch('/api/users/staff')
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setUsers(d.users || [])) .then((d) => setUsers(d.users || []))
.catch(() => {}) .catch(() => {})

View file

@ -99,6 +99,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
const [completeDialogOpen, setCompleteDialogOpen] = useState(false) const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null) const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
const [reminderDate, setReminderDate] = useState('') const [reminderDate, setReminderDate] = useState('')
const [completionDate, setCompletionDate] = useState('')
const [completeSubmitting, setCompleteSubmitting] = useState(false) const [completeSubmitting, setCompleteSubmitting] = useState(false)
const now = new Date() const now = new Date()
@ -114,6 +115,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
if (!isCompleted) { if (!isCompleted) {
setImageRightFiled(null) setImageRightFiled(null)
setReminderDate('') setReminderDate('')
setCompletionDate(new Date().toISOString().split('T')[0])
setCompleteDialogOpen(true) setCompleteDialogOpen(true)
return return
} }
@ -141,6 +143,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
const body: any = { status: 'COMPLETED' } const body: any = { status: 'COMPLETED' }
if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled
if (reminderDate) body.reminderDate = reminderDate if (reminderDate) body.reminderDate = reminderDate
if (completionDate) body.completedAt = completionDate
const res = await fetch(`/api/tasks/${task.id}`, { const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -437,6 +440,10 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Complete Task</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>Complete Task</DialogTitle></DialogHeader>
<div className="space-y-4 py-2"> <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" />
</div>
<div> <div>
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p> <p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -17,6 +17,7 @@ export interface AfwCustomer {
EMail: string | null EMail: string | null
Prod1Code: string | null Prod1Code: string | null
ChangedDate: Date | null ChangedDate: Date | null
ANotId: string | null
} }
/** /**
@ -153,7 +154,7 @@ export async function fetchAfwCustomers(
modifiedSince?: Date modifiedSince?: Date
): Promise<AfwCustomer[]> { ): Promise<AfwCustomer[]> {
let query = ` let query = `
SELECT SELECT
CustId, CustId,
FirmNameCust, FirmNameCust,
LastName, LastName,
@ -166,7 +167,8 @@ export async function fetchAfwCustomers(
BusPhone, BusPhone,
EMail, EMail,
Prod1Code, Prod1Code,
ChangedDate ChangedDate,
ANotId
FROM AFW_Customer FROM AFW_Customer
WHERE Active = 'Y' WHERE Active = 'Y'
` `

View file

@ -241,7 +241,19 @@ async function syncCustomers(
while (attempt < retries) { while (attempt < retries) {
try { try {
const afwCustomers = await fetchAfwCustomers(modifiedSince) const [afwCustomers, designations] = await Promise.all([
fetchAfwCustomers(modifiedSince),
prisma.designation.findMany({
where: { isActive: true, afwAnotId: { not: null } },
select: { id: true, afwAnotId: true },
}),
])
// Map afwAnotId → local designation id for fast lookup
const anotIdToDesignationId = new Map(
designations.map((d) => [d.afwAnotId!, d.id])
)
let inserted = 0 let inserted = 0
let updated = 0 let updated = 0
@ -252,17 +264,36 @@ async function syncCustomers(
const clientData = mapAfwCustomerToClient(afwCustomer) const clientData = mapAfwCustomerToClient(afwCustomer)
// If the AFW customer has an ANotId that maps to a local designation, apply it
const designationId = afwCustomer.ANotId
? (anotIdToDesignationId.get(afwCustomer.ANotId) ?? null)
: null
if (existingClient) { if (existingClient) {
// Check if update is needed
if (shouldUpdateRecord(existingClient.amsModifiedAt, afwCustomer.ChangedDate)) { if (shouldUpdateRecord(existingClient.amsModifiedAt, afwCustomer.ChangedDate)) {
await prisma.client.update({ await prisma.client.update({
where: { id: existingClient.id }, where: { id: existingClient.id },
data: clientData, data: {
...clientData,
...(designationId !== null && { designationId }),
},
})
updated++
} else if (designationId !== null && existingClient.designationId !== designationId) {
// Record hasn't changed but designation needs updating
await prisma.client.update({
where: { id: existingClient.id },
data: { designationId },
}) })
updated++ updated++
} }
} else { } else {
await prisma.client.create({ data: clientData }) await prisma.client.create({
data: {
...clientData,
...(designationId !== null && { designationId }),
},
})
inserted++ inserted++
} }
} }
@ -275,7 +306,7 @@ async function syncCustomers(
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error : new Error(String(error)) lastError = error instanceof Error ? error : new Error(String(error))
attempt++ attempt++
if (attempt < retries) { if (attempt < retries) {
const backoffMs = Math.pow(2, attempt) * 1000 const backoffMs = Math.pow(2, attempt) * 1000
console.log(`⚠️ Customer sync attempt ${attempt} failed, retrying in ${backoffMs}ms...`) console.log(`⚠️ Customer sync attempt ${attempt} failed, retrying in ${backoffMs}ms...`)