From 881b82f986fcf8a12ff96aee472d4d6e55a5c505 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 31 Mar 2026 16:04:05 +0000 Subject: [PATCH] Tasks: clickable filter cards, transfer tasks, policy type display, client policy filter --- ondeck/src/app/(dashboard)/clients/page.tsx | 3 +- .../src/app/(dashboard)/tasks/page-client.tsx | 297 ++++++++++++++++-- ondeck/src/app/(dashboard)/tasks/page.tsx | 2 +- ondeck/src/app/api/clients/route.ts | 5 + .../src/app/api/tasks/bulk-transfer/route.ts | 72 +++++ ondeck/src/app/api/tasks/route.ts | 1 + ondeck/src/components/clients/client-list.tsx | 21 +- ondeck/src/lib/auth.ts | 1 + 8 files changed, 364 insertions(+), 38 deletions(-) create mode 100644 ondeck/src/app/api/tasks/bulk-transfer/route.ts diff --git a/ondeck/src/app/(dashboard)/clients/page.tsx b/ondeck/src/app/(dashboard)/clients/page.tsx index 87a7397..c53024f 100644 --- a/ondeck/src/app/(dashboard)/clients/page.tsx +++ b/ondeck/src/app/(dashboard)/clients/page.tsx @@ -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, diff --git a/ondeck/src/app/(dashboard)/tasks/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/page-client.tsx index abf20ba..950ad71 100644 --- a/ondeck/src/app/(dashboard)/tasks/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page-client.tsx @@ -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 }) { {task.client.name} )} - {task.policy?.policyNumber && ( + {task.policy && ( Policy:{' '} - {task.policy.policyNumber} + + {[task.policy.policyNumber, task.policy.policyType].filter(Boolean).join(' ยท ')} + )} @@ -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 (
@@ -366,58 +437,146 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users } )}
- {/* Stats */} -
- + {/* Stats / filter cards */} +
+ {/* Overdue */} + handleCardFilter('overdue')} + className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === 'overdue' ? 'ring-2 ring-red-500' : ''}`} + > Overdue
{overdue.length}
-

Require immediate attention

+

Past due

- + + {/* Due Today */} + handleCardFilter('today')} + className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === 'today' ? 'ring-2 ring-orange-500' : ''}`} + > - Due Today + This Week
{dueToday.length}
-

Tasks due today

+

Days 1โ€“7

- + + {/* 1 Week Out */} + handleCardFilter('1week')} + className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === '1week' ? 'ring-2 ring-blue-500' : ''}`} + > - Upcoming + 1 Week Out -
{upcoming.length}
-

Future tasks

+
{due1Week.length}
+

Days 8โ€“14

+
+
+ + {/* 2 Weeks Out */} + handleCardFilter('2weeks')} + className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === '2weeks' ? 'ring-2 ring-indigo-500' : ''}`} + > + + 2 Weeks Out + + + +
{due2Weeks.length}
+

Days 8โ€“21

+
+
+ + {/* Custom range */} + handleCardFilter('custom')} + className={`cursor-pointer transition-all hover:shadow-md ${cardFilter === 'custom' ? 'ring-2 ring-violet-500' : ''}`} + > + + Custom + + + +

Select date range

+ {/* Custom date range picker */} + {cardFilter === 'custom' && ( +
+ From + setCustomFrom(e.target.value)} + className="h-9 rounded-md border border-input bg-background px-3 text-sm" + /> + To + setCustomTo(e.target.value)} + className="h-9 rounded-md border border-input bg-background px-3 text-sm" + /> + {customFrom && customTo && ( + + {applyCardFilter(baseVisible).length} task(s) in range + + )} +
+ )} + {/* Task list */} - - All Tasks -
- {(['assigned', 'completed', 'all'] as const).map((f) => ( - - ))} + + Transfer{cardFilter ? ` ${visibleTasks.length}` : ''} + + )} +
+ {(['assigned', 'completed', 'all'] as const).map((f) => ( + + ))} +
@@ -436,6 +595,76 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users } )}
+ + {/* Transfer dialog */} + + + + + + Transfer Tasks + + +
+

+ Transfer {visibleTasks.length} task(s) + {cardFilter && <> from the current filtered view} from{' '} + + {viewingUser?.displayName ?? viewingUser?.email ?? 'this user'} + {' '} + to another team member. +

+
+ + +
+
+ + + + +
+
) } diff --git a/ondeck/src/app/(dashboard)/tasks/page.tsx b/ondeck/src/app/(dashboard)/tasks/page.tsx index cec8856..d78f060 100644 --- a/ondeck/src/app/(dashboard)/tasks/page.tsx +++ b/ondeck/src/app/(dashboard)/tasks/page.tsx @@ -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 } } }, }, diff --git a/ondeck/src/app/api/clients/route.ts b/ondeck/src/app/api/clients/route.ts index b9f8961..512d1fa 100644 --- a/ondeck/src/app/api/clients/route.ts +++ b/ondeck/src/app/api/clients/route.ts @@ -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({ diff --git a/ondeck/src/app/api/tasks/bulk-transfer/route.ts b/ondeck/src/app/api/tasks/bulk-transfer/route.ts new file mode 100644 index 0000000..a217ddd --- /dev/null +++ b/ondeck/src/app/api/tasks/bulk-transfer/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/tasks/route.ts b/ondeck/src/app/api/tasks/route.ts index 59d0ef6..7691a18 100644 --- a/ondeck/src/app/api/tasks/route.ts +++ b/ondeck/src/app/api/tasks/route.ts @@ -60,6 +60,7 @@ export async function GET(request: NextRequest) { select: { id: true, policyNumber: true, + policyType: true, expirationDate: true, }, }, diff --git a/ondeck/src/components/clients/client-list.tsx b/ondeck/src/components/clients/client-list.tsx index 9a5758c..ef704c9 100644 --- a/ondeck/src/components/clients/client-list.tsx +++ b/ondeck/src/components/clients/client-list.tsx @@ -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('cards') + const [showNoPolicies, setShowNoPolicies] = useState(false) const [users, setUsers] = useState([]) 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 (
{/* Filters */} @@ -151,6 +158,16 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis ))} + {hasActiveFilters && (