From a4dfa0db59fcd097f176bccd482601e1adc84e06 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 19 May 2026 03:24:14 +0000 Subject: [PATCH] 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. --- ondeck/ARCHITECTURE.md | 101 ++++++++++++++++++ .../src/app/(dashboard)/clients/[id]/page.tsx | 10 +- .../src/app/(dashboard)/tasks/page-client.tsx | 13 +++ ondeck/src/app/(dashboard)/tasks/page.tsx | 11 +- ondeck/src/app/api/clients/[id]/route.ts | 4 +- ondeck/src/app/api/clients/route.ts | 4 +- .../src/app/api/policy-groups/[id]/route.ts | 9 +- ondeck/src/app/api/tasks/[id]/route.ts | 4 +- .../src/app/api/users/claims-staff/route.ts | 24 +++++ ondeck/src/app/api/users/staff/route.ts | 24 +++++ ondeck/src/app/auth/signin/page.tsx | 11 +- .../src/components/clients/client-detail.tsx | 4 +- ondeck/src/components/clients/client-list.tsx | 2 +- ondeck/src/components/tasks/task-card.tsx | 7 ++ ondeck/src/lib/sync/afw-queries.ts | 6 +- ondeck/src/lib/sync/sync-engine.ts | 41 ++++++- 16 files changed, 247 insertions(+), 28 deletions(-) create mode 100644 ondeck/ARCHITECTURE.md create mode 100644 ondeck/src/app/api/users/claims-staff/route.ts create mode 100644 ondeck/src/app/api/users/staff/route.ts diff --git a/ondeck/ARCHITECTURE.md b/ondeck/ARCHITECTURE.md new file mode 100644 index 0000000..18a9846 --- /dev/null +++ b/ondeck/ARCHITECTURE.md @@ -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 diff --git a/ondeck/src/app/(dashboard)/clients/[id]/page.tsx b/ondeck/src/app/(dashboard)/clients/[id]/page.tsx index 3ae686f..96e9e13 100644 --- a/ondeck/src/app/(dashboard)/clients/[id]/page.tsx +++ b/ondeck/src/app/(dashboard)/clients/[id]/page.tsx @@ -69,9 +69,15 @@ export default async function ClientDetailPage({ ], AND: [ { + // Keep ALL open tasks regardless of age; only hide ancient terminal tasks OR: [ - { dueDate: { gte: cutoff } }, - { status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } }, + { status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] } }, + { + AND: [ + { status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } }, + { dueDate: { gte: cutoff } }, + ], + }, ], }, ], diff --git a/ondeck/src/app/(dashboard)/tasks/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/page-client.tsx index 27c08ce..c027a88 100644 --- a/ondeck/src/app/(dashboard)/tasks/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page-client.tsx @@ -107,6 +107,7 @@ function TaskCard({ task: initial }: { task: Task }) { const [completeDialogOpen, setCompleteDialogOpen] = useState(false) const [imageRightFiled, setImageRightFiled] = useState(null) const [reminderDate, setReminderDate] = useState('') + const [completionDate, setCompletionDate] = useState('') const [completeSubmitting, setCompleteSubmitting] = useState(false) const now = new Date() @@ -117,6 +118,7 @@ function TaskCard({ task: initial }: { task: Task }) { if (!isCompleted) { setImageRightFiled(null) setReminderDate('') + setCompletionDate(new Date().toISOString().split('T')[0]) setCompleteDialogOpen(true) return } @@ -144,6 +146,7 @@ function TaskCard({ task: initial }: { task: Task }) { const body: any = { status: 'COMPLETED' } if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled if (reminderDate) body.reminderDate = reminderDate + if (completionDate) body.completedAt = completionDate const res = await fetch(`/api/tasks/${task.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -436,6 +439,16 @@ function TaskCard({ task: initial }: { task: Task }) { Complete Task
+
+

Date completed

+ setCompletionDate(e.target.value)} + max={new Date().toISOString().split('T')[0]} + className="w-48" + /> +

Did you file in ImageRight?

diff --git a/ondeck/src/app/(dashboard)/tasks/page.tsx b/ondeck/src/app/(dashboard)/tasks/page.tsx index 3e4a02c..5b3066d 100644 --- a/ondeck/src/app/(dashboard)/tasks/page.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page.tsx @@ -36,11 +36,16 @@ export default async function TasksPage() { { policyGroup: { renewalDate: { gte: cutoff } } }, ], }, - // Only show recent or terminal tasks + // Keep ALL open tasks regardless of age; only hide ancient terminal tasks { OR: [ - { dueDate: { gte: cutoff } }, - { status: { in: TERMINAL_STATUSES } }, + { status: { notIn: TERMINAL_STATUSES } }, + { + AND: [ + { status: { in: TERMINAL_STATUSES } }, + { dueDate: { gte: cutoff } }, + ], + }, ], }, ], diff --git a/ondeck/src/app/api/clients/[id]/route.ts b/ondeck/src/app/api/clients/[id]/route.ts index ae6567a..5777ee6 100644 --- a/ondeck/src/app/api/clients/[id]/route.ts +++ b/ondeck/src/app/api/clients/[id]/route.ts @@ -62,9 +62,9 @@ export async function GET( 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 isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') + const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') || userRoles.includes('Claims') if (!isManagerOrAdmin) { // Check if user is assigned via policy personnel (exec or CSR) diff --git a/ondeck/src/app/api/clients/route.ts b/ondeck/src/app/api/clients/route.ts index 7e968e1..619fb1e 100644 --- a/ondeck/src/app/api/clients/route.ts +++ b/ondeck/src/app/api/clients/route.ts @@ -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 isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') + const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') || userRoles.includes('Claims') if (!isManagerOrAdmin) { const rbacFilter = { diff --git a/ondeck/src/app/api/policy-groups/[id]/route.ts b/ondeck/src/app/api/policy-groups/[id]/route.ts index 74c318b..b8d1ecd 100644 --- a/ondeck/src/app/api/policy-groups/[id]/route.ts +++ b/ondeck/src/app/api/policy-groups/[id]/route.ts @@ -77,14 +77,17 @@ export async function PATCH( } const userRoles = (session.user as any).roles || [] - if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager') const { id } = await params const body = await request.json() 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({ where: { id }, include: { policies: { select: { id: true } } }, diff --git a/ondeck/src/app/api/tasks/[id]/route.ts b/ondeck/src/app/api/tasks/[id]/route.ts index a7ecadc..95e1b5d 100644 --- a/ondeck/src/app/api/tasks/[id]/route.ts +++ b/ondeck/src/app/api/tasks/[id]/route.ts @@ -39,7 +39,7 @@ export async function PATCH( const body = await request.json() const { - status, notes, naReason, imageRightFiled, reminderDate, + status, notes, naReason, imageRightFiled, reminderDate, completedAt, title, description, priority, department, dueDate, clientId, policyId, policyGroupId, assignedUserIds, } = body @@ -59,7 +59,7 @@ export async function PATCH( if (status !== undefined) { updateData.status = status if (status === 'COMPLETED') { - updateData.completedAt = new Date() + updateData.completedAt = completedAt ? new Date(completedAt) : new Date() updateData.completedBy = userId } else { updateData.completedAt = null diff --git a/ondeck/src/app/api/users/claims-staff/route.ts b/ondeck/src/app/api/users/claims-staff/route.ts new file mode 100644 index 0000000..acd05ba --- /dev/null +++ b/ondeck/src/app/api/users/claims-staff/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/users/staff/route.ts b/ondeck/src/app/api/users/staff/route.ts new file mode 100644 index 0000000..d92d73b --- /dev/null +++ b/ondeck/src/app/api/users/staff/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/auth/signin/page.tsx b/ondeck/src/app/auth/signin/page.tsx index 25bc5f7..ba95b68 100644 --- a/ondeck/src/app/auth/signin/page.tsx +++ b/ondeck/src/app/auth/signin/page.tsx @@ -11,11 +11,14 @@ export default function SignInPage() { Seubert
-

- Shape Process +

+ Horizon

-

- Horizon keeps your team on top of every renewal, every client, every deadline. +

+ Operations Hub +

+

+ Stay on top of every renewal, every client, every deadline.

diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index 704756f..13753eb 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -115,11 +115,11 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic const [addMemberId, setAddMemberId] = useState('') useEffect(() => { - fetch('/api/users?isActive=true&limit=200') + fetch('/api/users/staff') .then((r) => r.json()) .then((d) => setAllUsers(d.users || [])) .catch(() => {}) - fetch('/api/users?isActive=true&limit=200&department=Claims') + fetch('/api/users/claims-staff') .then((r) => r.json()) .then((d) => setClaimsUsers(d.users || [])) .catch(() => {}) diff --git a/ondeck/src/components/clients/client-list.tsx b/ondeck/src/components/clients/client-list.tsx index 9b2d6eb..e084791 100644 --- a/ondeck/src/components/clients/client-list.tsx +++ b/ondeck/src/components/clients/client-list.tsx @@ -86,7 +86,7 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis } useEffect(() => { - fetch('/api/users?isActive=true&limit=200&fields=department') + fetch('/api/users/staff') .then((r) => r.json()) .then((d) => setUsers(d.users || [])) .catch(() => {}) diff --git a/ondeck/src/components/tasks/task-card.tsx b/ondeck/src/components/tasks/task-card.tsx index eee4e9f..b997be8 100644 --- a/ondeck/src/components/tasks/task-card.tsx +++ b/ondeck/src/components/tasks/task-card.tsx @@ -99,6 +99,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC const [completeDialogOpen, setCompleteDialogOpen] = useState(false) const [imageRightFiled, setImageRightFiled] = useState(null) const [reminderDate, setReminderDate] = useState('') + const [completionDate, setCompletionDate] = useState('') const [completeSubmitting, setCompleteSubmitting] = useState(false) const now = new Date() @@ -114,6 +115,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC if (!isCompleted) { setImageRightFiled(null) setReminderDate('') + setCompletionDate(new Date().toISOString().split('T')[0]) setCompleteDialogOpen(true) return } @@ -141,6 +143,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC const body: any = { status: 'COMPLETED' } if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled if (reminderDate) body.reminderDate = reminderDate + if (completionDate) body.completedAt = completionDate const res = await fetch(`/api/tasks/${task.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -437,6 +440,10 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC Complete Task

+
+

Date completed

+ setCompletionDate(e.target.value)} max={new Date().toISOString().split('T')[0]} className="w-48" /> +

Did you file in ImageRight?

diff --git a/ondeck/src/lib/sync/afw-queries.ts b/ondeck/src/lib/sync/afw-queries.ts index df6019a..c78c1d6 100644 --- a/ondeck/src/lib/sync/afw-queries.ts +++ b/ondeck/src/lib/sync/afw-queries.ts @@ -17,6 +17,7 @@ export interface AfwCustomer { EMail: string | null Prod1Code: string | null ChangedDate: Date | null + ANotId: string | null } /** @@ -153,7 +154,7 @@ export async function fetchAfwCustomers( modifiedSince?: Date ): Promise { let query = ` - SELECT + SELECT CustId, FirmNameCust, LastName, @@ -166,7 +167,8 @@ export async function fetchAfwCustomers( BusPhone, EMail, Prod1Code, - ChangedDate + ChangedDate, + ANotId FROM AFW_Customer WHERE Active = 'Y' ` diff --git a/ondeck/src/lib/sync/sync-engine.ts b/ondeck/src/lib/sync/sync-engine.ts index bfbb859..c31cd93 100644 --- a/ondeck/src/lib/sync/sync-engine.ts +++ b/ondeck/src/lib/sync/sync-engine.ts @@ -241,7 +241,19 @@ async function syncCustomers( while (attempt < retries) { 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 updated = 0 @@ -252,17 +264,36 @@ async function syncCustomers( 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) { - // Check if update is needed if (shouldUpdateRecord(existingClient.amsModifiedAt, afwCustomer.ChangedDate)) { await prisma.client.update({ 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++ } } else { - await prisma.client.create({ data: clientData }) + await prisma.client.create({ + data: { + ...clientData, + ...(designationId !== null && { designationId }), + }, + }) inserted++ } } @@ -275,7 +306,7 @@ async function syncCustomers( } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) attempt++ - + if (attempt < retries) { const backoffMs = Math.pow(2, attempt) * 1000 console.log(`⚠️ Customer sync attempt ${attempt} failed, retrying in ${backoffMs}ms...`)