Compare commits
2 commits
e6485b35dc
...
970ffe2864
| Author | SHA1 | Date | |
|---|---|---|---|
| 970ffe2864 | |||
| a4dfa0db59 |
18 changed files with 364 additions and 40 deletions
101
ondeck/ARCHITECTURE.md
Normal file
101
ondeck/ARCHITECTURE.md
Normal 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
|
||||
|
|
@ -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 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -91,6 +97,9 @@ export default async function ClientDetailPage({
|
|||
policyGroup: {
|
||||
select: { id: true, name: true, renewalDate: true },
|
||||
},
|
||||
template: {
|
||||
select: { id: true, name: true, level: true },
|
||||
},
|
||||
assignments: {
|
||||
include: {
|
||||
user: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
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 } from 'lucide-react'
|
||||
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'
|
||||
|
|
@ -47,6 +47,11 @@ interface Task {
|
|||
policyGroup: { id: string; name: string | null; renewalDate: string | Date | null } | null
|
||||
assignments: TaskAssignment[]
|
||||
taskNotes: TaskNote[]
|
||||
daysOffset?: number | null
|
||||
timing?: string | null
|
||||
templateId?: string | null
|
||||
isAdHoc?: boolean | null
|
||||
template?: { id?: string; name: string; level?: string } | null
|
||||
}
|
||||
|
||||
interface SimpleUser { id: string; displayName: string | null; email: string; department?: string | null }
|
||||
|
|
@ -87,7 +92,20 @@ function PriorityDot({ priority }: { priority: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function TaskCard({ task: initial }: { task: Task }) {
|
||||
function buildOriginLabel(task: Task): 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 TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivileged?: boolean }) {
|
||||
const [task, setTask] = useState(initial)
|
||||
const [notes, setNotes] = useState<TaskNote[]>(initial.taskNotes ?? [])
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
|
|
@ -107,6 +125,7 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
|
||||
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [completionDate, setCompletionDate] = useState('')
|
||||
const [completeSubmitting, setCompleteSubmitting] = useState(false)
|
||||
|
||||
const now = new Date()
|
||||
|
|
@ -117,6 +136,7 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
if (!isCompleted) {
|
||||
setImageRightFiled(null)
|
||||
setReminderDate('')
|
||||
setCompletionDate(new Date().toISOString().split('T')[0])
|
||||
setCompleteDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
|
@ -144,6 +164,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' },
|
||||
|
|
@ -400,8 +421,22 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
<div className="border-b border-l border-border" />
|
||||
)}
|
||||
{/* Row 3: Level | Edit */}
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center text-muted-foreground">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -436,6 +471,16 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
<DialogTitle>Complete Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -503,6 +548,7 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
task={task as unknown as EditableTask}
|
||||
isPrivileged={isPrivileged}
|
||||
onUpdated={(updated) => {
|
||||
setTask((prev) => ({ ...prev, ...updated }))
|
||||
}}
|
||||
|
|
@ -1033,7 +1079,7 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
<TaskCard key={task.id} task={task} isPrivileged={isPrivileged} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -53,6 +58,7 @@ 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 } },
|
||||
assignments: {
|
||||
include: { user: { select: { displayName: true, email: true } } },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -35,12 +35,17 @@ export async function GET(
|
|||
{ policyGroupId: null },
|
||||
{ policyGroup: { renewalDate: { gte: cutoff } } },
|
||||
]
|
||||
// Only show recent or terminal tasks
|
||||
// Keep ALL open tasks regardless of age; only hide ancient terminal tasks
|
||||
where.AND = [
|
||||
{
|
||||
OR: [
|
||||
{ dueDate: { gte: cutoff } },
|
||||
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||
{ status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||
{
|
||||
AND: [
|
||||
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||
{ dueDate: { gte: cutoff } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -67,6 +72,9 @@ export async function GET(
|
|||
policyGroup: {
|
||||
select: { id: true, name: true, renewalDate: true },
|
||||
},
|
||||
template: {
|
||||
select: { id: true, name: true, level: true },
|
||||
},
|
||||
taskNotes: {
|
||||
include: { user: { select: { id: true, displayName: true, email: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
ondeck/src/app/api/users/claims-staff/route.ts
Normal file
24
ondeck/src/app/api/users/claims-staff/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
24
ondeck/src/app/api/users/staff/route.ts
Normal file
24
ondeck/src/app/api/users/staff/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -11,11 +11,14 @@ export default function SignInPage() {
|
|||
<img src="/seubert-logo-black.png" alt="Seubert" width={220} style={{ filter: 'brightness(0) invert(1)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-4xl font-bold text-white leading-tight">
|
||||
Shape Process
|
||||
<h2 className="text-5xl font-bold text-white leading-tight tracking-tight">
|
||||
Horizon
|
||||
</h2>
|
||||
<p className="mt-4 text-white/70 text-lg">
|
||||
Horizon keeps your team on top of every renewal, every client, every deadline.
|
||||
<p className="mt-1 text-white/60 text-base font-medium uppercase tracking-widest">
|
||||
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>
|
||||
</div>
|
||||
<p className="text-white/40 text-sm">
|
||||
|
|
|
|||
|
|
@ -115,11 +115,11 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
const [addMemberId, setAddMemberId] = useState<string>('')
|
||||
|
||||
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(() => {})
|
||||
|
|
@ -671,6 +671,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
key={task.id}
|
||||
task={task}
|
||||
onUpdated={() => refreshTasks()}
|
||||
isPrivileged={canManageGroups}
|
||||
/>
|
||||
))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(() => {})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { MessageSquare, CheckCircle2, RotateCcw, Ban, Pencil, ChevronDown, X } from 'lucide-react'
|
||||
import { MessageSquare, CheckCircle2, RotateCcw, Ban, Pencil, ChevronDown, X, Info } from 'lucide-react'
|
||||
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
|
||||
|
|
@ -42,6 +42,10 @@ export interface TaskCardTask {
|
|||
isAdHoc?: boolean
|
||||
taskGroup?: string | null
|
||||
anchorGroup?: { id: string; name: string; renewalDate: string | Date } | null
|
||||
daysOffset?: number | null
|
||||
timing?: string | null
|
||||
templateId?: string | null
|
||||
template?: { id?: string; name: string; level?: string } | null
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
|
|
@ -73,13 +77,27 @@ 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}`
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskCardTask
|
||||
onUpdated?: (updated: Partial<TaskCardTask>) => void
|
||||
showClient?: boolean
|
||||
isPrivileged?: boolean
|
||||
}
|
||||
|
||||
export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskCardProps) {
|
||||
export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivileged = false }: TaskCardProps) {
|
||||
const [task, setTask] = useState(initial)
|
||||
const [notes, setNotes] = useState(initial.taskNotes ?? [])
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
|
|
@ -99,6 +117,7 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
|
|||
const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
|
||||
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [completionDate, setCompletionDate] = useState('')
|
||||
const [completeSubmitting, setCompleteSubmitting] = useState(false)
|
||||
|
||||
const now = new Date()
|
||||
|
|
@ -114,6 +133,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 +161,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' },
|
||||
|
|
@ -403,8 +424,22 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
|
|||
<div className="border-b border-l border-border" />
|
||||
)}
|
||||
{/* Row 3: Level | Edit */}
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center text-muted-foreground">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -437,6 +472,10 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
|
|||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Complete Task</DialogTitle></DialogHeader>
|
||||
<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>
|
||||
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { UserSelectContent } from '@/components/ui/user-select-content'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { Pencil, Info } from 'lucide-react'
|
||||
|
||||
interface SimpleUser {
|
||||
id: string
|
||||
|
|
@ -56,6 +56,24 @@ export interface EditableTask {
|
|||
policy?: { id: string; policyNumber: string | null; policyType: string | null; expirationDate?: string | Date | null } | null
|
||||
policyGroup?: { id: string; name: string | null; renewalDate?: string | Date | null } | null
|
||||
assignments: Array<{ id?: string; user: { id?: string; displayName: string | null; email: string }; userId?: string }>
|
||||
daysOffset?: number | null
|
||||
timing?: string | null
|
||||
templateId?: string | null
|
||||
isAdHoc?: boolean | null
|
||||
template?: { id?: string; name: string; level?: string } | null
|
||||
}
|
||||
|
||||
function buildOriginLabel(task: EditableTask): 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.policyGroupId ? 'Group level' : task.policyId ? 'Policy level' : 'Client level'
|
||||
const templateName = task.template?.name ?? task.title
|
||||
return `${timing} · ${level} · Template: ${templateName}`
|
||||
}
|
||||
|
||||
interface TaskEditModalProps {
|
||||
|
|
@ -63,6 +81,7 @@ interface TaskEditModalProps {
|
|||
onOpenChange: (open: boolean) => void
|
||||
task: EditableTask
|
||||
onUpdated: (updated: any) => void
|
||||
isPrivileged?: boolean
|
||||
}
|
||||
|
||||
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']
|
||||
|
|
@ -74,7 +93,7 @@ const DEPARTMENTS = [
|
|||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
|
||||
export function TaskEditModal({ open, onOpenChange, task, onUpdated }: TaskEditModalProps) {
|
||||
export function TaskEditModal({ open, onOpenChange, task, onUpdated, isPrivileged = false }: TaskEditModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState('MEDIUM')
|
||||
|
|
@ -236,6 +255,14 @@ export function TaskEditModal({ open, onOpenChange, task, onUpdated }: TaskEditM
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Task Origin — managers/admins only */}
|
||||
{isPrivileged && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{buildOriginLabel(task)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Title *</label>
|
||||
|
|
|
|||
|
|
@ -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<AfwCustomer[]> {
|
||||
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'
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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...`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue