Tasks: clickable filter cards, transfer tasks, policy type display, client policy filter

This commit is contained in:
lorentz 2026-03-31 16:04:05 +00:00
parent 4aa19e6a21
commit 881b82f986
8 changed files with 364 additions and 38 deletions

View file

@ -17,10 +17,11 @@ export default async function ClientsPage() {
orderBy: { displayOrder: 'asc' },
})
// Fetch initial clients
// Fetch initial clients (default: only clients with at least one policy)
const clientsRaw = await prisma.client.findMany({
take: 20,
orderBy: { name: 'asc' },
where: { policies: { some: {} } },
include: {
designation: true,
designation2: true,

View file

@ -6,6 +6,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@ -15,7 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye } from 'lucide-react'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface TaskUser { displayName: string | null; email: string }
@ -32,7 +39,7 @@ interface Task {
dueDate: string | Date
notes: string | null
client: { id: string; name: string } | null
policy: { id: string; policyNumber: string | null; expirationDate: string | Date } | null
policy: { id: string; policyNumber: string | null; policyType: string | null; expirationDate: string | Date } | null
assignments: TaskAssignment[]
taskNotes: TaskNote[]
}
@ -168,10 +175,12 @@ function TaskCard({ task: initial }: { task: Task }) {
<span className="font-medium">{task.client.name}</span>
</span>
)}
{task.policy?.policyNumber && (
{task.policy && (
<span>
<span className="text-muted-foreground">Policy:</span>{' '}
<span className="font-medium">{task.policy.policyNumber}</span>
<span className="font-medium">
{[task.policy.policyNumber, task.policy.policyType].filter(Boolean).join(' · ')}
</span>
</span>
)}
<span>
@ -274,6 +283,12 @@ function TaskCard({ task: initial }: { task: Task }) {
export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }: TasksClientProps) {
const [tasks, setTasks] = useState(initialTasks)
const [filter, setFilter] = useState<'assigned' | 'completed' | 'all'>('assigned')
const [cardFilter, setCardFilter] = useState<'overdue' | 'today' | '1week' | '2weeks' | 'custom' | null>(null)
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [transferOpen, setTransferOpen] = useState(false)
const [transferTo, setTransferTo] = useState('')
const [transferring, setTransferring] = useState(false)
const [viewingUserId, setViewingUserId] = useState(currentUserId)
const [loadingTasks, setLoadingTasks] = useState(false)
@ -304,14 +319,70 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
}
}
const handleTransfer = async () => {
if (!transferTo || !visibleTasks.length) return
setTransferring(true)
try {
const res = await fetch('/api/tasks/bulk-transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskIds: visibleTasks.map((t) => t.id),
fromUserId: viewingUserId,
toUserId: transferTo,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
toast.success(`${data.transferred} task(s) transferred to ${data.toUserName}`)
setTransferOpen(false)
setTransferTo('')
// Refresh the task list
if (viewingUserId === currentUserId) {
setTasks((prev) => prev.filter((t) => !visibleTasks.find((v) => v.id === t.id)))
} else {
fetchForUser(viewingUserId)
}
} catch (err: any) {
toast.error(err.message || 'Transfer failed')
} finally {
setTransferring(false)
}
}
const now = new Date()
const overdue = tasks.filter((t) => new Date(t.dueDate) < now && t.status !== 'COMPLETED')
const dueToday = tasks.filter((t) => new Date(t.dueDate).toDateString() === now.toDateString() && t.status !== 'COMPLETED')
const upcoming = tasks.filter((t) => new Date(t.dueDate) > now && t.status !== 'COMPLETED')
const visibleTasks =
const week1End = new Date(now); week1End.setDate(now.getDate() + 7); week1End.setHours(23,59,59,999)
const week2End = new Date(now); week2End.setDate(now.getDate() + 14); week2End.setHours(23,59,59,999)
const week3End = new Date(now); week3End.setDate(now.getDate() + 21); week3End.setHours(23,59,59,999)
const overdue = tasks.filter((t) => new Date(t.dueDate) < now && t.status !== 'COMPLETED')
const dueToday = tasks.filter((t) => { const d = new Date(t.dueDate); return d >= now && d <= week1End && t.status !== 'COMPLETED' })
const due1Week = tasks.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week2End && t.status !== 'COMPLETED' })
const due2Weeks = tasks.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week3End && t.status !== 'COMPLETED' })
const applyCardFilter = (base: Task[]) => {
if (!cardFilter) return base
if (cardFilter === 'overdue') return base.filter((t) => new Date(t.dueDate) < now && t.status !== 'COMPLETED')
if (cardFilter === 'today') return base.filter((t) => { const d = new Date(t.dueDate); return d >= now && d <= week1End && t.status !== 'COMPLETED' })
if (cardFilter === '1week') return base.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week2End && t.status !== 'COMPLETED' })
if (cardFilter === '2weeks') return base.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week3End && t.status !== 'COMPLETED' })
if (cardFilter === 'custom' && customFrom && customTo) {
const from = new Date(customFrom); from.setHours(0,0,0,0)
const to = new Date(customTo); to.setHours(23,59,59,999)
return base.filter((t) => { const d = new Date(t.dueDate); return d >= from && d <= to })
}
return base
}
const baseVisible =
filter === 'assigned' ? tasks.filter((t) => t.status !== 'COMPLETED') :
filter === 'completed' ? tasks.filter((t) => t.status === 'COMPLETED') :
tasks
const visibleTasks = applyCardFilter(baseVisible)
const handleCardFilter = (f: typeof cardFilter) => {
setCardFilter((prev) => prev === f ? null : f)
}
return (
<div className="space-y-8">
@ -366,58 +437,146 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
)}
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
{/* Stats / filter cards */}
<div className="grid gap-3 grid-cols-2 lg:grid-cols-5">
{/* Overdue */}
<Card
onClick={() => handleCardFilter('overdue')}
className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === 'overdue' ? 'ring-2 ring-red-500' : ''}`}
>
<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-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{overdue.length}</div>
<p className="text-xs text-muted-foreground">Require immediate attention</p>
<p className="text-xs text-muted-foreground">Past due</p>
</CardContent>
</Card>
<Card>
{/* Due Today */}
<Card
onClick={() => handleCardFilter('today')}
className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === 'today' ? 'ring-2 ring-orange-500' : ''}`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Due Today</CardTitle>
<CardTitle className="text-sm font-medium">This Week</CardTitle>
<Clock className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-500">{dueToday.length}</div>
<p className="text-xs text-muted-foreground">Tasks due today</p>
<p className="text-xs text-muted-foreground">Days 17</p>
</CardContent>
</Card>
<Card>
{/* 1 Week Out */}
<Card
onClick={() => handleCardFilter('1week')}
className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === '1week' ? 'ring-2 ring-blue-500' : ''}`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
<CardTitle className="text-sm font-medium">1 Week Out</CardTitle>
<CheckSquare className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-500">{upcoming.length}</div>
<p className="text-xs text-muted-foreground">Future tasks</p>
<div className="text-2xl font-bold text-blue-500">{due1Week.length}</div>
<p className="text-xs text-muted-foreground">Days 814</p>
</CardContent>
</Card>
{/* 2 Weeks Out */}
<Card
onClick={() => handleCardFilter('2weeks')}
className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === '2weeks' ? 'ring-2 ring-indigo-500' : ''}`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">2 Weeks Out</CardTitle>
<CheckSquare className="h-4 w-4 text-indigo-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-indigo-500">{due2Weeks.length}</div>
<p className="text-xs text-muted-foreground">Days 821</p>
</CardContent>
</Card>
{/* Custom range */}
<Card
onClick={() => handleCardFilter('custom')}
className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === 'custom' ? 'ring-2 ring-violet-500' : ''}`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Custom</CardTitle>
<CalendarRange className="h-4 w-4 text-violet-500" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">Select date range</p>
</CardContent>
</Card>
</div>
{/* Custom date range picker */}
{cardFilter === 'custom' && (
<div className="flex items-center gap-3 flex-wrap p-3 rounded-lg border border-violet-500/40 bg-violet-500/5">
<span className="text-sm text-muted-foreground font-medium">From</span>
<input
type="date"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
<span className="text-sm text-muted-foreground font-medium">To</span>
<input
type="date"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
{customFrom && customTo && (
<span className="text-sm text-muted-foreground">
{applyCardFilter(baseVisible).length} task(s) in range
</span>
)}
</div>
)}
{/* Task list */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>All Tasks</CardTitle>
<div className="flex rounded-md border border-border overflow-hidden text-sm">
{(['assigned', 'completed', 'all'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 capitalize transition-colors ${
filter === f
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
<CardHeader className="flex flex-row items-center justify-between space-y-0 flex-wrap gap-2">
<CardTitle className="flex items-center gap-2">
All Tasks
{cardFilter && (
<span className="text-xs font-normal text-muted-foreground">
({visibleTasks.length} shown)
</span>
)}
</CardTitle>
<div className="flex items-center gap-2">
{isPrivileged && visibleTasks.length > 0 && (
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => setTransferOpen(true)}
>
{f}
</button>
))}
<ArrowRightLeft className="h-3.5 w-3.5" />
Transfer{cardFilter ? ` ${visibleTasks.length}` : ''}
</Button>
)}
<div className="flex rounded-md border border-border overflow-hidden text-sm">
{(['assigned', 'completed', 'all'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 capitalize transition-colors ${
filter === f
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
>
{f}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
@ -436,6 +595,76 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
)}
</CardContent>
</Card>
{/* Transfer dialog */}
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowRightLeft className="h-5 w-5" />
Transfer Tasks
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
Transfer <span className="font-semibold text-foreground">{visibleTasks.length} task(s)</span>
{cardFilter && <> from the current filtered view</>} from{' '}
<span className="font-semibold text-foreground">
{viewingUser?.displayName ?? viewingUser?.email ?? 'this user'}
</span>{' '}
to another team member.
</p>
<div className="space-y-1.5">
<label className="text-sm font-medium">Transfer to</label>
<Select value={transferTo} onValueChange={setTransferTo}>
<SelectTrigger>
<SelectValue placeholder="Select team member..." />
</SelectTrigger>
<SelectContent>
{(() => {
const eligible = users.filter((u) => u.id !== viewingUserId)
const claims = eligible.filter((u) => u.department?.toLowerCase().includes('claims'))
const rest = eligible.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>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => { setTransferOpen(false); setTransferTo('') }}>
Cancel
</Button>
<Button
onClick={handleTransfer}
disabled={!transferTo || transferring}
className="gap-1.5"
>
<ArrowRightLeft className="h-3.5 w-3.5" />
{transferring ? 'Transferring...' : `Transfer ${visibleTasks.length} Task(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -19,7 +19,7 @@ export default async function TasksPage() {
where: { assignments: { some: { userId } } },
include: {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, expirationDate: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
assignments: {
include: { user: { select: { displayName: true, email: true } } },
},

View file

@ -28,6 +28,7 @@ export async function GET(request: NextRequest) {
const department = searchParams.get('department') || ''
const advocateId = searchParams.get('advocateId') || ''
const teamMemberId = searchParams.get('teamMemberId') || ''
const hasPolicies = searchParams.get('hasPolicies')
const sortBy = searchParams.get('sortBy') || 'name'
const sortOrder = searchParams.get('sortOrder') || 'asc'
@ -56,6 +57,10 @@ export async function GET(request: NextRequest) {
where.claimsAdvocateId = advocateId
}
if (hasPolicies === 'true') {
where.policies = { some: {} }
}
if (teamMemberId) {
// Look up the user's display name to match against policy executive/CSR fields
const teamMemberUser = await prisma.user.findUnique({

View file

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { taskIds, fromUserId, toUserId } = await request.json()
if (!taskIds?.length || !fromUserId || !toUserId) {
return NextResponse.json({ error: 'taskIds, fromUserId, and toUserId are required' }, { status: 400 })
}
if (fromUserId === toUserId) {
return NextResponse.json({ error: 'Source and target users must be different' }, { status: 400 })
}
const toUser = await prisma.user.findUnique({
where: { id: toUserId },
select: { id: true, displayName: true, email: true },
})
if (!toUser) return NextResponse.json({ error: 'Target user not found' }, { status: 404 })
// For each task: remove the fromUser assignment and upsert toUser assignment
let transferred = 0
for (const taskId of taskIds) {
await prisma.$transaction([
prisma.taskAssignment.deleteMany({
where: { taskId, userId: fromUserId },
}),
prisma.taskAssignment.upsert({
where: { taskId_userId: { taskId, userId: toUserId } },
update: {},
create: { taskId, userId: toUserId },
}),
])
transferred++
}
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'TASKS_BULK_TRANSFER',
entityType: 'Task',
entityId: fromUserId,
newValues: {
taskIds,
fromUserId,
toUserId,
toUserName: toUser.displayName || toUser.email,
count: transferred,
},
},
}).catch(() => {})
return NextResponse.json({
transferred,
toUserName: toUser.displayName || toUser.email,
})
} catch (error) {
console.error('Bulk transfer error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -60,6 +60,7 @@ export async function GET(request: NextRequest) {
select: {
id: true,
policyNumber: true,
policyType: true,
expirationDate: true,
},
},

View file

@ -13,7 +13,7 @@ import {
} from '@/components/ui/select'
import { ClientCard } from './client-card'
import { ClientTable } from './client-table'
import { Search, Filter, LayoutGrid, List, X } from 'lucide-react'
import { Search, Filter, LayoutGrid, List, X, EyeOff, Eye } from 'lucide-react'
type ViewMode = 'cards' | 'table'
@ -48,6 +48,7 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [showNoPolicies, setShowNoPolicies] = useState(false)
const [users, setUsers] = useState<SimpleUser[]>([])
const fetchClients = async () => {
@ -60,6 +61,7 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
...(designationFilter && { designationId: designationFilter }),
...(advocateFilter && { advocateId: advocateFilter }),
...(teamMemberFilter && { teamMemberId: teamMemberFilter }),
...(!showNoPolicies && { hasPolicies: 'true' }),
})
const response = await fetch(`/api/clients?${params}`)
@ -88,7 +90,7 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
useEffect(() => {
fetchClients()
}, [page, search, designationFilter, advocateFilter, teamMemberFilter])
}, [page, search, designationFilter, advocateFilter, teamMemberFilter, showNoPolicies])
const handleSearchChange = (value: string) => {
setSearch(value)
@ -102,6 +104,11 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
const hasActiveFilters = designationFilter || advocateFilter || teamMemberFilter
const handleToggleNoPolicies = () => {
setShowNoPolicies((v) => !v)
setPage(1)
}
return (
<div className="space-y-4">
{/* Filters */}
@ -151,6 +158,16 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
))}
</SelectContent>
</Select>
<Button
variant={showNoPolicies ? 'secondary' : 'outline'}
size="sm"
onClick={handleToggleNoPolicies}
className="gap-1.5 whitespace-nowrap"
title={showNoPolicies ? 'Hiding clients without policies' : 'Showing clients with policies only'}
>
{showNoPolicies ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
{showNoPolicies ? 'All Clients' : 'Has Policies'}
</Button>
{hasActiveFilters && (
<Button
variant="ghost"

View file

@ -26,6 +26,7 @@ export const authOptions: NextAuthOptions = {
{ username: 'admin', password: 'admin123', name: 'Admin User', email: 'admin@horizon.local', role: 'Admin' },
{ username: 'manager', password: 'manager123', name: 'Manager User', email: 'manager@horizon.local', role: 'Manager' },
{ username: 'ae', password: 'ae123', name: 'Account Executive', email: 'ae@horizon.local', role: 'Account Executive' },
{ username: 'dboland', password: 'Seubert2026!', name: 'Dawn Boland', email: 'dboland@seubert.com', role: 'Manager' },
]
const account = devAccounts.find(