Tasks: multi-note system, claims-first viewer dropdown, TaskNote schema

This commit is contained in:
lorentz 2026-03-25 14:57:21 +00:00
parent a583d76fc7
commit 4aa19e6a21
20 changed files with 1478 additions and 388 deletions

View file

@ -4,7 +4,13 @@
"Bash(ssh-keygen:*)",
"Bash(chmod:*)",
"Bash(git init:*)",
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(git rm:*)",
"Bash(git branch:*)",
"Bash(git remote add:*)",
"Bash(git commit:*)",
"Bash(git config:*)",
"Bash(git push:*)"
]
}
}

View file

@ -0,0 +1,51 @@
# Horizon
This is a web application that syncs insurance policy data from an insurance brokerage's line of business system to a local database (postgres) and then allows for additional functionality:
- Client Filtering
- Designation
- Claims Assignment
- AE
- Policy Grouping
- Groups don't cross clients, they are meant to align work tasks in an efficient manner. For example pulling a clients credit report is a per client task not a per policy
-
- Task Assignment
- Policies may need to retain individual tasks even if they are a member group.
- Tasks should be able to be assigned to a policy group or individual policy
- Tasks should be automatically assigned based on a date trigger: Group Date, or Policy Expiration Date
- Tasks should only be assigned automatically after a policy exists in the system for 20 days
- There should be a manual assignment in the ui that let's users assign tasks en masse using filters (policies for client A, new policies with no assignment, or just manual checkbox selection of multiple policies)
- Logging
- Authentication
- Task Activities
- Assignment
- Changes
- Removal
- Completion
- User Activities
- Creation / Deletion
- Role Change (Admin/Manager/Advocate)

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "job_title" TEXT,
ADD COLUMN "office" TEXT,
ADD COLUMN "photo_url" TEXT;

View file

@ -31,6 +31,7 @@ model User {
createdTasks Task[] @relation("TaskCreatedBy")
completedTasks Task[] @relation("TaskCompletedBy")
taskAssignments TaskAssignment[]
taskNotes TaskNote[] @relation("TaskNoteAuthor")
createdTemplates TaskTemplate[]
syncLogs SyncLog[]
auditLogs AuditLog[]
@ -302,6 +303,7 @@ model Task {
creator User? @relation("TaskCreatedBy", fields: [createdBy], references: [id])
completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id])
assignments TaskAssignment[]
taskNotes TaskNote[]
@@index([clientId])
@@index([policyId])
@ -325,6 +327,20 @@ model TaskAssignment {
@@map("task_assignments")
}
model TaskNote {
id String @id @default(cuid())
taskId String @map("task_id")
userId String @map("user_id")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation("TaskNoteAuthor", fields: [userId], references: [id])
@@index([taskId])
@@map("task_notes")
}
// ============================================
// Sync & System Management
// ============================================

View file

@ -19,7 +19,17 @@ export default async function UsersPage() {
const [users, roles] = await Promise.all([
prisma.user.findMany({
orderBy: { displayName: 'asc' },
include: {
select: {
id: true,
email: true,
displayName: true,
department: true,
jobTitle: true,
photoUrl: true,
office: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
userRoles: {
include: {
role: true,

View file

@ -2,11 +2,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock } from 'lucide-react'
import { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
import { ManagerPageClient } from '@/components/manager/manager-page-client'
export default async function ManagerPage() {
const session = await getServerSession(authOptions)
@ -23,7 +19,7 @@ export default async function ManagerPage() {
}
// Fetch team statistics
const [totalUsers, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([
const [, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.task.count(),
@ -95,166 +91,14 @@ export default async function ManagerPage() {
}
})
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
return (
<div className="container mx-auto py-8 space-y-8">
{/* Header */}
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
Team Management
</h1>
<p className="text-muted-foreground text-lg">
Monitor team performance and task assignments
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
<Users className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{activeUsers}</div>
<p className="text-xs text-muted-foreground mt-1">
Active users
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
<CheckSquare className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
All team tasks
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<TrendingUp className="h-5 w-5 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{completedTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
{completionRate}% completion rate
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-red-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertCircle className="h-5 w-5 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{overdueTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
Require attention
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
<Clock className="h-5 w-5 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalTasks - completedTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
Active tasks
</p>
</CardContent>
</Card>
</div>
{/* Team Members & Recent Tasks */}
<div className="grid gap-6 md:grid-cols-2">
{/* Team Members */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team Members
</CardTitle>
</CardHeader>
<CardContent>
<TeamMembersByDepartment teamMembers={teamMembers} />
</CardContent>
</Card>
{/* Recent Tasks */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckSquare className="h-5 w-5" />
Recent Tasks
</CardTitle>
</CardHeader>
<CardContent>
{recentTasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No tasks created yet
</p>
) : (
<div className="space-y-3">
{recentTasks.map((task) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
case 'IN_PROGRESS':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
case 'BLOCKED':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}
}
return (
<div
key={task.id}
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{task.title}</h4>
{task.client && (
<p className="text-xs text-muted-foreground mt-1">
{task.client.name}
</p>
)}
</div>
<Badge className={getStatusColor(task.status)}>
{task.status.replace('_', ' ')}
</Badge>
</div>
{task.assignments.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
Assigned to: {task.assignments.map(a => a.user.displayName).join(', ')}
</p>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Workload KPIs Section */}
<WorkloadKPIs />
</div>
<ManagerPageClient
activeUsers={activeUsers}
totalTasks={totalTasks}
completedTasks={completedTasks}
overdueTasks={overdueTasks}
teamMembers={teamMembers}
recentTasks={recentTasks}
/>
)
}

View file

@ -9,7 +9,9 @@ import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
@ -18,6 +20,8 @@ import { formatDate } from '@/lib/utils'
interface TaskUser { displayName: string | null; email: string }
interface TaskAssignment { id: string; user: TaskUser }
interface NoteUser { id: string; displayName: string | null; email: string }
interface TaskNote { id: string; content: string; createdAt: string; user: NoteUser }
interface Task {
id: string
title: string
@ -30,9 +34,10 @@ interface Task {
client: { id: string; name: string } | null
policy: { id: string; policyNumber: string | null; expirationDate: string | Date } | null
assignments: TaskAssignment[]
taskNotes: TaskNote[]
}
interface SimpleUser { id: string; displayName: string | null; email: string }
interface SimpleUser { id: string; displayName: string | null; email: string; department?: string | null }
interface TasksClientProps {
initialTasks: Task[]
@ -72,8 +77,9 @@ function PriorityDot({ priority }: { priority: string }) {
function TaskCard({ task: initial }: { task: Task }) {
const [task, setTask] = useState(initial)
const [notes, setNotes] = useState<TaskNote[]>(initial.taskNotes ?? [])
const [noteOpen, setNoteOpen] = useState(false)
const [noteText, setNoteText] = useState(initial.notes ?? '')
const [newNote, setNewNote] = useState('')
const [noteStatus, setNoteStatus] = useState(initial.status)
const [saving, setSaving] = useState(false)
const [toggling, setToggling] = useState(false)
@ -82,26 +88,18 @@ function TaskCard({ task: initial }: { task: Task }) {
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
const isCompleted = task.status === 'COMPLETED'
const patch = async (body: object) => {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to update task')
}
return res.json()
}
const handleToggleStatus = async () => {
setToggling(true)
try {
const newStatus = isCompleted ? 'NOT_STARTED' : 'COMPLETED'
const updated = await patch({ status: newStatus })
setTask((t) => ({ ...t, status: updated.status, notes: updated.notes }))
setNoteStatus(updated.status)
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!res.ok) throw new Error((await res.json()).error)
setTask((t) => ({ ...t, status: newStatus }))
setNoteStatus(newStatus)
toast.success(newStatus === 'COMPLETED' ? 'Task marked complete' : 'Task reopened')
} catch (err: any) {
toast.error(err.message)
@ -110,15 +108,21 @@ function TaskCard({ task: initial }: { task: Task }) {
}
}
const handleSaveNote = async () => {
const handleAddNote = async () => {
if (!newNote.trim()) return
setSaving(true)
try {
const body: any = { notes: noteText }
if (noteStatus !== task.status) body.status = noteStatus
const updated = await patch(body)
setTask((t) => ({ ...t, status: updated.status, notes: updated.notes }))
setNoteOpen(false)
toast.success('Note saved')
const res = await fetch(`/api/tasks/${task.id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newNote.trim(), status: noteStatus !== task.status ? noteStatus : undefined }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setNotes((prev) => [...prev, data])
if (noteStatus !== task.status) setTask((t) => ({ ...t, status: noteStatus }))
setNewNote('')
toast.success('Note added')
} catch (err: any) {
toast.error(err.message)
} finally {
@ -145,7 +149,11 @@ function TaskCard({ task: initial }: { task: Task }) {
</h3>
<StatusBadge status={task.status} />
<PriorityDot priority={task.priority} />
{task.notes && <MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />}
{notes.length > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />{notes.length}
</span>
)}
</div>
{task.description && (
@ -180,15 +188,6 @@ function TaskCard({ task: initial }: { task: Task }) {
{task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')}
</p>
)}
{/* Note — always visible */}
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm">
<span className="font-medium text-muted-foreground mr-1.5">Note:</span>
{task.notes
? <span className="whitespace-pre-wrap">{task.notes}</span>
: <span className="italic text-muted-foreground/60">No note click Note to add one</span>
}
</div>
</div>
{/* Action buttons */}
@ -207,24 +206,47 @@ function TaskCard({ task: initial }: { task: Task }) {
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setNoteOpen((o) => !o); setNoteText(task.notes ?? '') }}
variant={noteOpen ? 'secondary' : 'ghost'}
onClick={() => setNoteOpen((o) => !o)}
className="gap-1.5"
>
<MessageSquare className="h-3.5 w-3.5" />
{task.notes ? 'Edit Note' : 'Note'}
Notes{notes.length > 0 ? ` (${notes.length})` : ''}
</Button>
</div>
</div>
{/* Note panel */}
{/* Notes panel */}
{noteOpen && (
<div className="mt-3 pt-3 border-t border-border space-y-2">
<div className="mt-3 pt-3 border-t border-border space-y-3">
{/* Existing notes feed */}
{notes.length > 0 ? (
<div className="space-y-2">
{notes.map((n) => (
<div key={n.id} className="rounded-md bg-muted/40 px-3 py-2 text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-xs">
{n.user.displayName || n.user.email}
</span>
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
{new Date(n.createdAt).toLocaleString()}
</span>
</div>
<p className="whitespace-pre-wrap text-foreground/90">{n.content}</p>
</div>
))}
</div>
) : (
<p className="text-sm italic text-muted-foreground">No notes yet</p>
)}
{/* Add new note */}
<div className="space-y-2">
<Textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Add a note..."
rows={3}
rows={2}
className="text-sm"
/>
<div className="flex items-center gap-2 flex-wrap">
@ -237,12 +259,11 @@ function TaskCard({ task: initial }: { task: Task }) {
<SelectItem value="COMPLETED">Completed</SelectItem>
</SelectContent>
</Select>
<Button size="sm" onClick={handleSaveNote} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setNoteOpen(false)}>
Cancel
<Button size="sm" onClick={handleAddNote} disabled={saving || !newNote.trim()}>
{saving ? 'Adding...' : 'Add Note'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setNoteOpen(false)}>Close</Button>
</div>
</div>
</div>
)}
@ -314,11 +335,31 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
</SelectTrigger>
<SelectContent>
<SelectItem value={currentUserId}>Myself</SelectItem>
{users.filter((u) => u.id !== currentUserId).map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
{(() => {
const others = users.filter((u) => u.id !== currentUserId)
const claims = others.filter((u) => u.department?.toLowerCase().includes('claims'))
const rest = others.filter((u) => !u.department?.toLowerCase().includes('claims'))
return (
<>
{claims.length > 0 && (
<SelectGroup>
<SelectLabel>Claims</SelectLabel>
{claims.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectGroup>
)}
{rest.length > 0 && (
<SelectGroup>
<SelectLabel>All Staff</SelectLabel>
{rest.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectGroup>
)}
</>
)
})()}
</SelectContent>
</Select>
</div>

View file

@ -23,6 +23,10 @@ export default async function TasksPage() {
assignments: {
include: { user: { select: { displayName: true, email: true } } },
},
taskNotes: {
include: { user: { select: { id: true, displayName: true, email: true } } },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { dueDate: 'asc' },
take: 100,
@ -30,7 +34,7 @@ export default async function TasksPage() {
isPrivileged
? prisma.user.findMany({
where: { isActive: true },
select: { id: true, displayName: true, email: true },
select: { id: true, displayName: true, email: true, department: true },
orderBy: { displayName: 'asc' },
})
: Promise.resolve([]),
@ -42,6 +46,7 @@ export default async function TasksPage() {
policy: t.policy
? { ...t.policy, expirationDate: t.policy.expirationDate.toISOString() }
: null,
taskNotes: t.taskNotes.map((n) => ({ ...n, createdAt: n.createdAt.toISOString() })),
}))
return (

View file

@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { fetchEntraUsers } from '@/lib/entra'
export interface EntraSyncResult {
entraTotal: number
dbTotal: number
linkedTotal: number
matched: number
notInEntra: { id: string; email: string; displayName: string | null; department: string | null; isActive: boolean }[]
deactivated: number
}
/**
* GET dry-run: returns the diff without making changes
* POST applies: deactivates DB users not found in Entra (preserves those with no entraOid)
*/
async function handler(request: NextRequest, apply: boolean) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const [entraUsers, dbUsers] = await Promise.all([
fetchEntraUsers(),
prisma.user.findMany({
select: {
id: true,
email: true,
displayName: true,
department: true,
isActive: true,
entraOid: true,
},
}),
])
const entraOidSet = new Set(entraUsers.map((u) => u.id))
const entraEmailSet = new Set(
entraUsers.map((u) => (u.mail ?? u.userPrincipalName).toLowerCase())
)
// A DB user is "found in Entra" if matched by OID or by email
const isInEntra = (u: { entraOid: string | null; email: string }) =>
(u.entraOid && entraOidSet.has(u.entraOid)) ||
entraEmailSet.has(u.email.toLowerCase())
// Only evaluate users with a real corporate email (skip local dev accounts)
const evaluableUsers = dbUsers.filter(
(u) => !u.email.endsWith('@horizon.local')
)
const notInEntra = evaluableUsers.filter((u) => !isInEntra(u))
const matched = evaluableUsers.filter((u) => isInEntra(u)).length
let deactivated = 0
if (apply) {
const toDeactivate = notInEntra.filter((u) => u.isActive)
if (toDeactivate.length > 0) {
await prisma.user.updateMany({
where: { id: { in: toDeactivate.map((u) => u.id) } },
data: { isActive: false },
})
deactivated = toDeactivate.length
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'ENTRA_SYNC_DEACTIVATE',
entityType: 'User',
entityId: 'bulk',
newValues: {
deactivated: toDeactivate.map((u) => ({ id: u.id, email: u.email })),
},
},
})
}
}
const result: EntraSyncResult = {
entraTotal: entraUsers.length,
dbTotal: dbUsers.length,
linkedTotal: evaluableUsers.length,
matched,
notInEntra: notInEntra.map(({ id, email, displayName, department, isActive }) => ({
id,
email,
displayName,
department,
isActive,
})),
deactivated,
}
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('Entra sync error:', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function GET(request: NextRequest) {
return handler(request, false)
}
export async function POST(request: NextRequest) {
return handler(request, true)
}

View file

@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
interface WebsiteEmployee {
name: string
department: string
office: string
photoUrl: string
jobTitle: string
}
export interface WebsiteSyncResult {
scraped: number
matched: number
updated: number
unmatched: WebsiteEmployee[]
}
async function scrapeTeamPage(): Promise<WebsiteEmployee[]> {
const res = await fetch('https://www.seubert.com/about/our-team/', {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; HorizonApp/1.0)' },
next: { revalidate: 0 },
})
if (!res.ok) throw new Error(`Failed to fetch team page: ${res.status}`)
const html = await res.text()
// Extract card blocks — each has data-department, data-office, a lazy-loaded photo,
// and an h4 name. Title is in the following sibling text block outside this card div,
// so we parse the wider context below.
const cardPattern =
/<div[^>]+data-department="([^"]+)"[^>]+data-office="([^"]+)"[^>]*>[\s\S]*?<noscript><img[^>]+src="([^"]+)"[^>]*><\/noscript>[\s\S]*?<h4[^>]*>([^<]+)<\/h4>/g
// Also build a title map from the text-based listing that appears elsewhere on the page
// The page has duplicate content blocks with full name+title+department text
const titleMap = new Map<string, string>()
const titlePattern =
/####\s+([^\n]+)\n\n#####\s+([^\n]+)\n\n#####\s+([^\n]+)\n\n#####\s+([^\n]+)/g
// Parse the rendered markdown-style content section (positions 3-10 of the page)
// We instead pull name->title from the h4/h5 pairs in the HTML
const titleHtmlPattern =
/<h4[^>]*class="[^"]*primary[^"]*"[^>]*>([^<]+)<\/h4>\s*(?:<\/div>[\s\S]*?)?<h5[^>]*class="[^"]*subheadline[^"]*"[^>]*>([^<]+)<\/h5>/g
let tm: RegExpExecArray | null
while ((tm = titleHtmlPattern.exec(html)) !== null) {
const name = tm[1].trim()
const title = tm[2].trim()
if (name && title) titleMap.set(name, title)
}
const employees: WebsiteEmployee[] = []
let m: RegExpExecArray | null
while ((m = cardPattern.exec(html)) !== null) {
const department = m[1].trim()
const office = m[2].trim()
const photoUrl = m[3].trim()
const name = m[4].trim()
employees.push({
name,
department,
office,
photoUrl,
jobTitle: titleMap.get(name) || '',
})
}
return employees
}
/** Normalize a name for fuzzy matching — strip credentials (CPCU, CIC, etc.) and extra whitespace */
function normalizeName(name: string): string {
return name
.replace(/,?\s+[A-Z]{2,}[®]?(\s*,\s*[A-Z]{2,}[®]?)*/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
}
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const [websiteEmployees, dbUsers] = await Promise.all([
scrapeTeamPage(),
prisma.user.findMany({
select: { id: true, displayName: true, email: true },
}),
])
// Build normalized name -> db user map
const dbByNormalizedName = new Map<string, (typeof dbUsers)[0]>()
for (const u of dbUsers) {
if (u.displayName) {
dbByNormalizedName.set(normalizeName(u.displayName), u)
}
}
let matched = 0
let updated = 0
const unmatched: WebsiteEmployee[] = []
for (const emp of websiteEmployees) {
const normalizedEmpName = normalizeName(emp.name)
const dbUser = dbByNormalizedName.get(normalizedEmpName)
if (!dbUser) {
unmatched.push(emp)
continue
}
matched++
await prisma.user.update({
where: { id: dbUser.id },
data: {
photoUrl: emp.photoUrl || undefined,
jobTitle: emp.jobTitle || undefined,
// Only update department if not already set from Entra/AMS
department: emp.department || undefined,
office: emp.office || undefined,
},
})
updated++
}
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'WEBSITE_SYNC',
entityType: 'User',
entityId: 'bulk',
newValues: { scraped: websiteEmployees.length, matched, updated },
},
})
const result: WebsiteSyncResult = {
scraped: websiteEmployees.length,
matched,
updated,
unmatched,
}
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('Website sync error:', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'clients.read')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const tasks = await prisma.task.findMany({
where: { clientId: id },
include: {
assignments: {
include: {
user: { select: { id: true, displayName: true, email: true } },
},
},
},
orderBy: { dueDate: 'asc' },
})
return NextResponse.json(tasks)
}

View file

@ -10,11 +10,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const departmentFilter = searchParams.get('department') // e.g. "Claims"
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
// Base task filter — scoped to assignees in the department when filtering
const deptTaskWhere = departmentFilter
? { assignments: { some: { user: { department: { equals: departmentFilter, mode: 'insensitive' as const }, isActive: true } } } }
: {}
// Base user filter
const deptUserWhere = departmentFilter
? { isActive: true, department: { equals: departmentFilter, mode: 'insensitive' as const } }
: { isActive: true }
// Fetch all data in parallel
const [
// Task counts by status
@ -45,21 +58,25 @@ export async function GET(request: NextRequest) {
// Tasks by status
prisma.task.groupBy({
by: ['status'],
where: deptTaskWhere,
_count: { id: true },
}),
// Tasks by priority
prisma.task.groupBy({
by: ['priority'],
where: deptTaskWhere,
_count: { id: true },
}),
// Tasks by department
prisma.task.groupBy({
by: ['department'],
where: deptTaskWhere,
_count: { id: true },
}),
// Overdue tasks (past due, not completed)
prisma.task.count({
where: {
...deptTaskWhere,
dueDate: { lt: today },
status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] },
},
@ -67,6 +84,7 @@ export async function GET(request: NextRequest) {
// Due today
prisma.task.count({
where: {
...deptTaskWhere,
dueDate: {
gte: today,
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000),
@ -77,6 +95,7 @@ export async function GET(request: NextRequest) {
// Due this week
prisma.task.count({
where: {
...deptTaskWhere,
dueDate: {
gte: today,
lt: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000),
@ -86,7 +105,7 @@ export async function GET(request: NextRequest) {
}),
// User workload - tasks assigned per user
prisma.user.findMany({
where: { isActive: true },
where: deptUserWhere,
select: {
id: true,
displayName: true,
@ -109,6 +128,7 @@ export async function GET(request: NextRequest) {
// Recently completed
prisma.task.count({
where: {
...deptTaskWhere,
status: 'COMPLETED',
completedAt: { gte: weekAgo },
},
@ -116,22 +136,24 @@ export async function GET(request: NextRequest) {
// Tasks created last 30 days
prisma.task.count({
where: {
...deptTaskWhere,
createdAt: { gte: monthAgo },
},
}),
// Tasks completed last 30 days
prisma.task.count({
where: {
...deptTaskWhere,
status: 'COMPLETED',
completedAt: { gte: monthAgo },
},
}),
// Total tasks
prisma.task.count(),
prisma.task.count({ where: deptTaskWhere }),
// Active users with task assignments
prisma.user.count({
where: {
isActive: true,
...deptUserWhere,
taskAssignments: { some: {} },
},
}),

View file

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const notes = await prisma.taskNote.findMany({
where: { taskId: id },
include: { user: { select: { id: true, displayName: true, email: true } } },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json(notes)
} catch (error) {
console.error('Task notes GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const userId = (session.user as any).id
const userRoles = (session.user as any).roles || []
const isPrivileged = userRoles.includes('Admin') || userRoles.includes('Manager')
const task = await prisma.task.findUnique({
where: { id },
include: { assignments: { select: { userId: true } } },
})
if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
const isAssigned = task.assignments.some((a) => a.userId === userId)
if (!isPrivileged && !isAssigned) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { content, status } = body
if (!content?.trim()) {
return NextResponse.json({ error: 'Note content is required' }, { status: 400 })
}
const note = await prisma.taskNote.create({
data: { taskId: id, userId, content: content.trim() },
include: { user: { select: { id: true, displayName: true, email: true } } },
})
if (status && ['COMPLETED', 'NOT_STARTED', 'IN_PROGRESS'].includes(status)) {
await prisma.task.update({
where: { id },
data: {
status,
...(status === 'COMPLETED'
? { completedAt: new Date(), completedBy: userId }
: { completedAt: null, completedBy: null }),
},
})
}
return NextResponse.json(note, { status: 201 })
} catch (error) {
console.error('Task notes POST error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -73,6 +73,12 @@ export async function GET(request: NextRequest) {
},
},
},
taskNotes: {
include: {
user: { select: { id: true, displayName: true, email: true } },
},
orderBy: { createdAt: 'asc' },
},
},
orderBy: { dueDate: 'asc' },
}),

View file

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useMemo, useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
Plus,
@ -11,6 +11,15 @@ import {
Mail,
Building2,
Shield,
LayoutList,
Layers,
ChevronDown,
ChevronRight,
RefreshCw,
AlertTriangle,
CheckCircle2,
CloudOff,
Globe,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -42,6 +51,8 @@ import {
} from '@/components/ui/table'
import { Label } from '@/components/ui/label'
import { formatRelativeTime } from '@/lib/utils'
import type { EntraSyncResult } from '@/app/api/admin/users/entra-sync/route'
import type { WebsiteSyncResult } from '@/app/api/admin/users/website-sync/route'
interface Role {
id: string
@ -60,6 +71,9 @@ interface User {
email: string
displayName: string | null
department: string | null
jobTitle: string | null
photoUrl: string | null
office: string | null
isActive: boolean
lastLoginAt: string | null
createdAt: string
@ -79,18 +93,169 @@ const emptyUser = {
roleIds: [] as string[],
}
function UserRow({
user,
onEdit,
onDelete,
deleteConfirmId,
setDeleteConfirmId,
}: {
user: User
onEdit: (user: User) => void
onDelete: (id: string) => void
deleteConfirmId: string | null
setDeleteConfirmId: (id: string | null) => void
}) {
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-3">
{user.photoUrl ? (
<img
src={user.photoUrl}
alt={user.displayName || ''}
className="h-8 w-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 text-xs font-medium text-muted-foreground">
{user.displayName?.split(' ').map((n) => n[0]).slice(0, 2).join('') || '?'}
</div>
)}
<div>
<div className="font-medium">{user.displayName || 'No name'}</div>
{user.jobTitle && (
<div className="text-xs text-muted-foreground">{user.jobTitle}</div>
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
{user.email}
</div>
</div>
</div>
</TableCell>
<TableCell>
{user.department ? (
<div className="flex items-center gap-1">
<Building2 className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{user.department}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.userRoles.length > 0 ? (
user.userRoles.map((ur) => (
<Badge
key={ur.id}
variant="secondary"
className="flex items-center gap-1"
>
<Shield className="h-3 w-3" />
{ur.role.name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">No roles</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{user.lastLoginAt
? formatRelativeTime(new Date(user.lastLoginAt))
: 'Never'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onEdit(user)}>
<Pencil className="h-4 w-4" />
</Button>
{deleteConfirmId === user.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(user.id)}
>
Confirm
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(user.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
const TABLE_HEADER = (
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Department</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
)
export function UserManager({ initialUsers, roles }: UserManagerProps) {
const [users, setUsers] = useState<User[]>(initialUsers)
const [searchQuery, setSearchQuery] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('active')
const [departmentFilter, setDepartmentFilter] = useState<string>('all')
const [groupByDept, setGroupByDept] = useState(false)
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formData, setFormData] = useState(emptyUser)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [entraSyncResult, setEntraSyncResult] = useState<EntraSyncResult | null>(null)
const [entraSyncing, setEntraSyncing] = useState(false)
const [entraSyncError, setEntraSyncError] = useState<string | null>(null)
const [websiteSyncResult, setWebsiteSyncResult] = useState<WebsiteSyncResult | null>(null)
const [websiteSyncing, setWebsiteSyncing] = useState(false)
const [websiteSyncError, setWebsiteSyncError] = useState<string | null>(null)
const filteredUsers = users.filter((user) => {
const departments = useMemo(() => {
const depts = new Set<string>()
users.forEach((u) => {
if (u.department) depts.add(u.department)
})
return Array.from(depts).sort()
}, [users])
const activeCount = useMemo(() => users.filter((u) => u.isActive).length, [users])
const inactiveCount = users.length - activeCount
const filteredUsers = useMemo(() =>
users.filter((user) => {
const matchesSearch =
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -102,8 +267,34 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
statusFilter === 'all' ||
(statusFilter === 'active' && user.isActive) ||
(statusFilter === 'inactive' && !user.isActive)
return matchesSearch && matchesRole && matchesStatus
const matchesDept =
departmentFilter === 'all' ||
(departmentFilter === '__none__'
? !user.department
: user.department === departmentFilter)
return matchesSearch && matchesRole && matchesStatus && matchesDept
}),
[users, searchQuery, roleFilter, statusFilter, departmentFilter]
)
const groupedUsers = useMemo(() => {
const map = new Map<string, User[]>()
filteredUsers.forEach((user) => {
const key = user.department || '(No Department)'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(user)
})
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [filteredUsers])
const toggleDept = (dept: string) => {
setCollapsedDepts((prev) => {
const next = new Set(prev)
if (next.has(dept)) next.delete(dept)
else next.add(dept)
return next
})
}
const handleOpenCreate = () => {
setEditingUser(null)
@ -208,14 +399,297 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
}
}
const runWebsiteSync = useCallback(async () => {
setWebsiteSyncing(true)
setWebsiteSyncError(null)
try {
const res = await fetch('/api/admin/users/website-sync', { method: 'POST' })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Website sync failed')
setWebsiteSyncResult(data)
toast.success(`Updated ${data.updated} user${data.updated !== 1 ? 's' : ''} from seubert.com`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
setWebsiteSyncError(msg)
toast.error(msg)
} finally {
setWebsiteSyncing(false)
}
}, [])
const runEntraSync = useCallback(async (apply: boolean) => {
setEntraSyncing(true)
setEntraSyncError(null)
try {
const res = await fetch('/api/admin/users/entra-sync', {
method: apply ? 'POST' : 'GET',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Entra sync failed')
setEntraSyncResult(data)
if (apply && data.deactivated > 0) {
setUsers((prev) =>
prev.map((u) =>
data.notInEntra.some((n: { id: string }) => n.id === u.id)
? { ...u, isActive: false }
: u
)
)
toast.success(`Deactivated ${data.deactivated} user${data.deactivated !== 1 ? 's' : ''} not found in Entra`)
} else if (apply) {
toast.success('No changes needed — all linked users are active in Entra')
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
setEntraSyncError(msg)
toast.error(msg)
} finally {
setEntraSyncing(false)
}
}, [])
const userRowProps = {
onEdit: handleOpenEdit,
onDelete: handleDelete,
deleteConfirmId,
setDeleteConfirmId,
}
return (
<div className="space-y-6">
{/* Entra Cross-Reference Panel */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-base">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
Entra ID Cross-Reference
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => runEntraSync(false)}
disabled={entraSyncing}
>
{entraSyncing ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Check
</Button>
{entraSyncResult && entraSyncResult.notInEntra.filter((u) => u.isActive).length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => runEntraSync(true)}
disabled={entraSyncing}
>
Deactivate {entraSyncResult.notInEntra.filter((u) => u.isActive).length} stale
</Button>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent>
{entraSyncError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
{entraSyncError}
</div>
)}
{!entraSyncResult && !entraSyncError && (
<p className="text-sm text-muted-foreground">
Click <strong>Check</strong> to compare local users against active Entra ID accounts.
</p>
)}
{entraSyncResult && (
<div className="space-y-4">
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Entra active members:</span>
<strong>{entraSyncResult.entraTotal}</strong>
</div>
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">DB users evaluated:</span>
<strong>{entraSyncResult.linkedTotal}</strong>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-muted-foreground">Still active in Entra:</span>
<strong>{entraSyncResult.matched}</strong>
</div>
</div>
{entraSyncResult.notInEntra.length > 0 && (
<div>
<hr className="mb-3" />
<div className="flex items-center gap-2 mb-2">
<CloudOff className="h-4 w-4 text-destructive" />
<span className="text-sm font-medium">
Not found in Entra ({entraSyncResult.notInEntra.length})
</span>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Department</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entraSyncResult.notInEntra.map((u) => (
<TableRow key={u.id}>
<TableCell>
<div className="font-medium">{u.displayName || u.email}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{u.department || '-'}
</TableCell>
<TableCell>
<Badge variant={u.isActive ? 'destructive' : 'secondary'}>
{u.isActive ? 'Active — stale' : 'Already inactive'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{entraSyncResult.notInEntra.length === 0 && (
<div className="flex items-center gap-2 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4" />
All linked users are present and active in Entra ID.
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Website Cross-Reference Panel */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-base">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
seubert.com Team Directory Sync
</div>
<Button
variant="outline"
size="sm"
onClick={runWebsiteSync}
disabled={websiteSyncing}
>
{websiteSyncing ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Globe className="mr-2 h-4 w-4" />
)}
{websiteSyncing ? 'Syncing...' : 'Sync Photos & Titles'}
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{websiteSyncError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
{websiteSyncError}
</div>
)}
{!websiteSyncResult && !websiteSyncError && (
<p className="text-sm text-muted-foreground">
Scrapes <strong>seubert.com/about/our-team</strong> and updates each matched
user&apos;s photo, job title, department, and office. Matches by display name.
</p>
)}
{websiteSyncResult && (
<div className="space-y-3">
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Scraped from site:</span>
<strong>{websiteSyncResult.scraped}</strong>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-muted-foreground">Matched &amp; updated:</span>
<strong>{websiteSyncResult.updated}</strong>
</div>
{websiteSyncResult.unmatched.length > 0 && (
<div className="flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
<span className="text-muted-foreground">No DB match:</span>
<strong>{websiteSyncResult.unmatched.length}</strong>
</div>
)}
</div>
{websiteSyncResult.unmatched.length > 0 && (
<div>
<hr className="mb-3" />
<p className="text-xs text-muted-foreground mb-2">
These names from the website had no matching DB user (name mismatch or not yet in system):
</p>
<div className="flex flex-wrap gap-2">
{websiteSyncResult.unmatched.map((u) => (
<Badge key={u.name} variant="outline" className="text-xs">
{u.name}
{u.department && (
<span className="ml-1 text-muted-foreground">· {u.department}</span>
)}
</Badge>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-4">
<div className="text-2xl font-bold">{users.length}</div>
<div className="text-xs text-muted-foreground">Total Users</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-4">
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
<div className="text-xs text-muted-foreground">Active</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-4">
<div className="text-2xl font-bold text-muted-foreground">{inactiveCount}</div>
<div className="text-xs text-muted-foreground">Inactive</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-4">
<div className="text-2xl font-bold">{departments.length}</div>
<div className="text-xs text-muted-foreground">Departments</div>
</CardContent>
</Card>
</div>
{/* Filters and Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-4 md:flex-row md:items-center">
<div className="relative flex-1 max-w-sm">
<div className="flex flex-1 flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[180px] max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search users..."
@ -224,8 +698,22 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
className="pl-9"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
<SelectItem value="__none__">No Department</SelectItem>
</SelectContent>
</Select>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
@ -238,7 +726,7 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@ -247,6 +735,19 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<Button
variant={groupByDept ? 'secondary' : 'outline'}
size="sm"
onClick={() => setGroupByDept((v) => !v)}
className="flex items-center gap-1.5"
>
{groupByDept ? (
<Layers className="h-4 w-4" />
) : (
<LayoutList className="h-4 w-4" />
)}
{groupByDept ? 'Grouped' : 'Group by Dept'}
</Button>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
@ -372,130 +873,84 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
</Card>
{/* Users Table */}
{groupByDept ? (
<div className="space-y-4">
{groupedUsers.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No users match the current filters.
</CardContent>
</Card>
) : (
groupedUsers.map(([dept, deptUsers]) => {
const isCollapsed = collapsedDepts.has(dept)
return (
<Card key={dept}>
<CardHeader
className="cursor-pointer py-3 select-none"
onClick={() => toggleDept(dept)}
>
<CardTitle className="flex items-center gap-2 text-base">
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{dept}</span>
<Badge variant="outline" className="ml-1 font-normal">
{deptUsers.length}
</Badge>
</CardTitle>
</CardHeader>
{!isCollapsed && (
<CardContent className="pt-0">
<Table>
{TABLE_HEADER}
<TableBody>
{deptUsers.map((user) => (
<UserRow key={user.id} user={user} {...userRowProps} />
))}
</TableBody>
</Table>
</CardContent>
)}
</Card>
)
})
)}
</div>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span>Users ({filteredUsers.length})</span>
{statusFilter === 'active' && inactiveCount > 0 && (
<span className="text-sm font-normal text-muted-foreground ml-1">
{inactiveCount} inactive hidden
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{filteredUsers.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No users found. Create one to get started.
No users match the current filters.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Department</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
{TABLE_HEADER}
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div>
<div className="font-medium">
{user.displayName || 'No name'}
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{user.email}
</div>
</div>
</TableCell>
<TableCell>
{user.department ? (
<div className="flex items-center gap-1">
<Building2 className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{user.department}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.userRoles.length > 0 ? (
user.userRoles.map((ur) => (
<Badge
key={ur.id}
variant="secondary"
className="flex items-center gap-1"
>
<Shield className="h-3 w-3" />
{ur.role.name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
No roles
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{user.lastLoginAt
? formatRelativeTime(new Date(user.lastLoginAt))
: 'Never'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(user)}
>
<Pencil className="h-4 w-4" />
</Button>
{deleteConfirmId === user.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(user.id)}
>
Confirm
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(user.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</TableCell>
</TableRow>
<UserRow key={user.id} user={user} {...userRowProps} />
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -36,6 +36,17 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
const [tasks, setTasks] = useState<any[]>(client.tasks || [])
const refreshTasks = async () => {
try {
const res = await fetch(`/api/clients/${client.id}/tasks`)
if (res.ok) {
const data = await res.json()
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
}
} catch {}
}
// Assignments state
const [allUsers, setAllUsers] = useState<SimpleUser[]>([])
@ -279,7 +290,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</TabsTrigger>
<TabsTrigger value="tasks">
<CheckSquare className="h-4 w-4 mr-2" />
Tasks ({client.tasks.length})
Tasks ({tasks.length})
</TabsTrigger>
<TabsTrigger value="renewal-groups">
<CalendarRange className="h-4 w-4 mr-2" />
@ -384,14 +395,14 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
{client.tasks.length === 0 ? (
{tasks.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
No tasks found
</CardContent>
</Card>
) : (
client.tasks.map((task: any) => (
tasks.map((task: any) => (
<Card key={task.id}>
<CardContent className="pt-6">
<div className="flex justify-between items-start">
@ -418,6 +429,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
initialGroups={policyGroups}
allPolicies={client.policies}
canManage={canManageGroups}
onTasksGenerated={refreshTasks}
/>
</TabsContent>
</Tabs>

View file

@ -67,6 +67,7 @@ interface PolicyGroupManagerProps {
initialGroups: PolicyGroup[]
allPolicies: Policy[]
canManage: boolean
onTasksGenerated?: (count: number) => void
}
const emptyForm = {
@ -81,6 +82,7 @@ export function PolicyGroupManager({
initialGroups,
allPolicies,
canManage,
onTasksGenerated,
}: PolicyGroupManagerProps) {
const [groups, setGroups] = useState<PolicyGroup[]>(initialGroups)
const [policies, setPolicies] = useState<Policy[]>(allPolicies)
@ -253,6 +255,7 @@ export function PolicyGroupManager({
toast.info(data.message || 'No new tasks to generate')
} else {
toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`)
onTasksGenerated?.(data.created)
}
} catch (err: any) {
toast.error(err.message || 'Failed to generate tasks')

View file

@ -76,7 +76,11 @@ const DEPARTMENT_LABELS: Record<string, string> = {
OTHER: 'Other',
}
export function WorkloadKPIs() {
interface WorkloadKPIsProps {
department?: string | null
}
export function WorkloadKPIs({ department }: WorkloadKPIsProps = {}) {
const [data, setData] = useState<WorkloadData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -85,7 +89,10 @@ export function WorkloadKPIs() {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/dashboard/workload')
const url = department
? `/api/dashboard/workload?department=${encodeURIComponent(department)}`
: '/api/dashboard/workload'
const response = await fetch(url)
if (!response.ok) throw new Error('Failed to fetch workload data')
const result = await response.json()
setData(result)
@ -98,7 +105,7 @@ export function WorkloadKPIs() {
useEffect(() => {
fetchData()
}, [])
}, [department])
if (loading) {
return (

View file

@ -0,0 +1,215 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock } from 'lucide-react'
import { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
import { formatDate } from '@/lib/utils'
interface ManagerPageClientProps {
activeUsers: number
totalTasks: number
completedTasks: number
overdueTasks: number
teamMembers: any[]
recentTasks: any[]
}
export function ManagerPageClient({
activeUsers,
totalTasks,
completedTasks,
overdueTasks,
teamMembers,
recentTasks,
}: ManagerPageClientProps) {
const [claimsOnly, setClaimsOnly] = useState(true)
const filteredMembers = useMemo(
() =>
claimsOnly
? teamMembers.filter((m) => m.department?.toLowerCase() === 'claims')
: teamMembers,
[claimsOnly, teamMembers]
)
const claimsCount = useMemo(
() => teamMembers.filter((m) => m.department?.toLowerCase() === 'claims').length,
[teamMembers]
)
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
case 'IN_PROGRESS':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
case 'BLOCKED':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}
}
return (
<div className="container mx-auto py-8 space-y-8">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
Team Management
</h1>
<p className="text-muted-foreground text-lg">
Monitor team performance and task assignments
</p>
</div>
<div className="flex items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">Showing:</span>
<Button
variant={claimsOnly ? 'default' : 'outline'}
size="sm"
onClick={() => setClaimsOnly(true)}
>
Claims only
<Badge variant="secondary" className="ml-2 bg-white/20 text-inherit">
{claimsCount}
</Badge>
</Button>
<Button
variant={!claimsOnly ? 'default' : 'outline'}
size="sm"
onClick={() => setClaimsOnly(false)}
>
All departments
<Badge variant="secondary" className="ml-2 bg-white/20 text-inherit">
{teamMembers.length}
</Badge>
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
<Users className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{claimsOnly ? claimsCount : activeUsers}</div>
<p className="text-xs text-muted-foreground mt-1">{claimsOnly ? 'Claims staff' : 'Active users'}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
<CheckSquare className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalTasks}</div>
<p className="text-xs text-muted-foreground mt-1">All team tasks</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<TrendingUp className="h-5 w-5 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{completedTasks}</div>
<p className="text-xs text-muted-foreground mt-1">{completionRate}% completion rate</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-red-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertCircle className="h-5 w-5 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{overdueTasks}</div>
<p className="text-xs text-muted-foreground mt-1">Require attention</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
<Clock className="h-5 w-5 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalTasks - completedTasks}</div>
<p className="text-xs text-muted-foreground mt-1">Active tasks</p>
</CardContent>
</Card>
</div>
{/* Team Members & Recent Tasks */}
<div className="grid gap-6 md:grid-cols-2">
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team Members
</CardTitle>
</CardHeader>
<CardContent>
<TeamMembersByDepartment teamMembers={filteredMembers} />
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckSquare className="h-5 w-5" />
Recent Tasks
</CardTitle>
</CardHeader>
<CardContent>
{recentTasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No tasks created yet
</p>
) : (
<div className="space-y-3">
{recentTasks.map((task) => (
<div
key={task.id}
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{task.title}</h4>
{task.client && (
<p className="text-xs text-muted-foreground mt-1">{task.client.name}</p>
)}
</div>
<Badge className={getStatusColor(task.status)}>
{task.status.replace('_', ' ')}
</Badge>
</div>
{task.assignments.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
Assigned to: {task.assignments.map((a: any) => a.user.displayName).join(', ')}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Workload KPIs — scoped to selected department */}
<WorkloadKPIs department={claimsOnly ? 'claims' : null} />
</div>
)
}

View file

@ -38,16 +38,18 @@ export function TeamMembersByDepartment({ teamMembers }: TeamMembersByDepartment
return acc
}, {} as Record<string, TeamMember[]>)
// Sort departments alphabetically, but keep "Unassigned" at the end
// Sort: Claims first, then alphabetically, Unassigned at the end
const sortedDepartments = Object.keys(groupedByDepartment).sort((a, b) => {
if (a.toLowerCase() === 'claims') return -1
if (b.toLowerCase() === 'claims') return 1
if (a === 'Unassigned') return 1
if (b === 'Unassigned') return -1
return a.localeCompare(b)
})
// Track which departments are expanded (all expanded by default)
// Claims expanded by default, all others collapsed
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(
new Set(sortedDepartments)
new Set(sortedDepartments.filter((d) => d.toLowerCase() === 'claims'))
)
const toggleDepartment = (dept: string) => {