seubert-claims/ondeck/src/components/manager/team-members-by-department.tsx
lorentz c20d6b8f1a Fix: align overdue task counts across all surfaces
Manager page and workload API were bounding overdue by a configurable
window (e.g. last 30 days), while the tasks page counted ANY open task
past due with no lower bound. This caused Mimi/Dawn's numbers to diverge.

All three surfaces now use the same definition: open task + dueDate < today.
2026-05-20 17:10:09 +00:00

150 lines
4.9 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { ChevronDown, ChevronRight, Users } from 'lucide-react'
interface TeamMember {
id: string
displayName: string | null
email: string
department: string | null
taskAssignments: {
task: {
status: string
priority: string
dueDate: Date | null
}
}[]
userRoles: {
role: {
name: string
}
}[]
}
interface TeamMembersByDepartmentProps {
teamMembers: TeamMember[]
overdueWindowDays: number
}
export function TeamMembersByDepartment({ teamMembers, overdueWindowDays: _overdueWindowDays }: TeamMembersByDepartmentProps) {
const today = new Date()
today.setHours(0, 0, 0, 0)
// Group members by department
const groupedByDepartment = teamMembers.reduce((acc, member) => {
const dept = member.department || 'Unassigned'
if (!acc[dept]) {
acc[dept] = []
}
acc[dept].push(member)
return acc
}, {} as Record<string, TeamMember[]>)
// 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)
})
// Claims expanded by default, all others collapsed
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(
new Set(sortedDepartments.filter((d) => d.toLowerCase() === 'claims'))
)
const toggleDepartment = (dept: string) => {
setExpandedDepts(prev => {
const next = new Set(prev)
if (next.has(dept)) {
next.delete(dept)
} else {
next.add(dept)
}
return next
})
}
if (teamMembers.length === 0) {
return (
<p className="text-sm text-muted-foreground text-center py-8">
No team members found
</p>
)
}
return (
<div className="space-y-3">
{sortedDepartments.map((dept) => {
const members = groupedByDepartment[dept]
const isExpanded = expandedDepts.has(dept)
return (
<div key={dept} className="border rounded-lg overflow-hidden">
<button
onClick={() => toggleDepartment(dept)}
className="w-full flex items-center justify-between p-3 bg-muted/50 hover:bg-muted transition-colors text-left"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{dept}</span>
</div>
<Badge variant="secondary" className="text-xs">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Badge>
</button>
{isExpanded && (
<div className="divide-y">
{members.map((member) => {
const activeTasks = member.taskAssignments.length
const overdue = member.taskAssignments.filter(
a => a.task.dueDate &&
new Date(a.task.dueDate) < today
).length
const roles = member.userRoles.map(ur => ur.role.name).join(', ')
return (
<div
key={member.id}
className="flex items-center justify-between p-4 hover:bg-accent/50 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{member.displayName || member.email}</h3>
{roles && (
<Badge variant="outline" className="text-xs">
{roles}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{member.email}</p>
</div>
<div className="text-right space-y-1">
<div className="text-sm font-medium">
{activeTasks} active {activeTasks === 1 ? 'task' : 'tasks'}
</div>
{overdue > 0 && (
<div className="text-xs text-red-600">
{overdue} overdue
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}