seubert-claims/ondeck/src/app/(dashboard)/tasks/page-client.tsx

966 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useCallback, useEffect } from 'react'
import { toast } from 'sonner'
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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { UserSelectContent } from '@/components/ui/user-select-content'
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { formatDate } from '@/lib/utils'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
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
description: string | null
status: string
priority: string
department: string
dueDate: string | Date
notes: string | null
client: { id: string; name: string } | null
policy: { id: string; policyNumber: string | null; policyType: string | null; expirationDate: string | Date } | null
policyGroup: { id: string; name: string | null; renewalDate: string | Date | null } | null
assignments: TaskAssignment[]
taskNotes: TaskNote[]
}
interface SimpleUser { id: string; displayName: string | null; email: string; department?: string | null }
interface TasksClientProps {
initialTasks: Task[]
currentUserId: string
isPrivileged: boolean
users: SimpleUser[]
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
COMPLETED: 'bg-green-500/15 text-green-700 dark:text-green-400',
IN_PROGRESS: 'bg-blue-500/15 text-blue-700 dark:text-blue-400',
NOT_STARTED: 'bg-muted text-muted-foreground',
BLOCKED: 'bg-red-500/15 text-red-700 dark:text-red-400',
NA: 'bg-muted text-muted-foreground',
CANCELLED: 'bg-muted text-muted-foreground line-through',
}
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${map[status] ?? 'bg-muted text-muted-foreground'}`}>
{status.replace('_', ' ')}
</span>
)
}
function PriorityDot({ priority }: { priority: string }) {
const map: Record<string, string> = {
HIGH: 'text-red-500',
MEDIUM: 'text-yellow-500',
LOW: 'text-green-500',
}
return (
<span className={`text-xs font-medium ${map[priority] ?? 'text-muted-foreground'}`}>
{priority}
</span>
)
}
function TaskCard({ task: initial }: { task: Task }) {
const [task, setTask] = useState(initial)
const [notes, setNotes] = useState<TaskNote[]>(initial.taskNotes ?? [])
const [noteOpen, setNoteOpen] = useState(false)
const [newNote, setNewNote] = useState('')
const [noteStatus, setNoteStatus] = useState(initial.status)
const [saving, setSaving] = useState(false)
const [toggling, setToggling] = useState(false)
const [naDialogOpen, setNaDialogOpen] = useState(false)
const [naReason, setNaReason] = useState('')
const [naSubmitting, setNaSubmitting] = useState(false)
const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
const [reminderDate, setReminderDate] = useState('')
const [completeSubmitting, setCompleteSubmitting] = useState(false)
const now = new Date()
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
const isCompleted = task.status === 'COMPLETED'
const handleToggleStatus = async () => {
if (!isCompleted) {
setImageRightFiled(null)
setReminderDate('')
setCompleteDialogOpen(true)
return
}
setToggling(true)
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'NOT_STARTED' }),
})
if (!res.ok) throw new Error((await res.json()).error)
setTask((t) => ({ ...t, status: 'NOT_STARTED' }))
setNoteStatus('NOT_STARTED')
toast.success('Task reopened')
} catch (err: any) {
toast.error(err.message)
} finally {
setToggling(false)
}
}
const handleConfirmComplete = async () => {
setCompleteSubmitting(true)
try {
const body: any = { status: 'COMPLETED' }
if (imageRightFiled !== null) body.imageRightFiled = imageRightFiled
if (reminderDate) body.reminderDate = reminderDate
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error((await res.json()).error)
setTask((t) => ({ ...t, status: 'COMPLETED' }))
setNoteStatus('COMPLETED')
setCompleteDialogOpen(false)
toast.success('Task marked complete')
} catch (err: any) {
toast.error(err.message)
} finally {
setCompleteSubmitting(false)
}
}
const handleMarkNA = async () => {
if (!naReason.trim()) return
setNaSubmitting(true)
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'NA', naReason: naReason.trim() }),
})
if (!res.ok) throw new Error((await res.json()).error)
setTask((t) => ({ ...t, status: 'NA' }))
setNoteStatus('NA')
setNaDialogOpen(false)
setNaReason('')
toast.success('Task marked N/A')
} catch (err: any) {
toast.error(err.message)
} finally {
setNaSubmitting(false)
}
}
const handleAddNote = async () => {
if (!newNote.trim()) return
setSaving(true)
try {
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 {
setSaving(false)
}
}
return (
<div
className={`rounded-lg border p-4 transition-shadow hover:shadow-md ${
isCompleted
? 'border-border/50 opacity-75'
: isOverdue
? 'border-red-500/40 bg-red-500/5 dark:bg-red-500/10'
: 'border-border'
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{/* Title row */}
<div className="flex flex-wrap items-center gap-2 mb-1.5">
<h3 className={`font-semibold ${isCompleted ? 'line-through text-muted-foreground' : ''}`}>
{task.title}
</h3>
<StatusBadge status={task.status} />
<PriorityDot priority={task.priority} />
{task.policyGroup ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-purple-500/10 text-purple-700 dark:text-purple-400">Group</span>
) : task.policy ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-700 dark:text-blue-400">Policy</span>
) : (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground">Client</span>
)}
{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 && (
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{task.description}</p>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
{task.client && (
<span>
<span className="text-muted-foreground">Client:</span>{' '}
<span className="font-medium">{task.client.name}</span>
</span>
)}
{task.policyGroup && (
<span>
<span className="text-muted-foreground">Group:</span>{' '}
<span className="font-medium">
{task.policyGroup.name ?? (task.policyGroup.renewalDate ? formatDate(task.policyGroup.renewalDate) : task.policyGroup.id)}
</span>
</span>
)}
{task.policy && (
<span>
<span className="text-muted-foreground">Policy:</span>{' '}
<span className="font-medium">
{[task.policy.policyNumber, task.policy.policyType].filter(Boolean).join(' · ')}
</span>
</span>
)}
<span>
<span className="text-muted-foreground">Due:</span>{' '}
<span className={`font-medium ${isOverdue ? 'text-red-500' : ''}`}>
<span suppressHydrationWarning>{formatDate(task.dueDate)}</span>
</span>
</span>
</div>
{task.assignments.length > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
Assigned to:{' '}
{task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')}
</p>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant={isCompleted ? 'outline' : 'default'}
onClick={handleToggleStatus}
disabled={toggling}
className="gap-1.5"
>
{isCompleted
? <><RotateCcw className="h-3.5 w-3.5" /> Reopen</>
: <><CheckCircle2 className="h-3.5 w-3.5" /> Complete</>
}
</Button>
{task.status !== 'COMPLETED' && task.status !== 'NA' && task.status !== 'CANCELLED' && (
<Button
size="sm"
variant="ghost"
onClick={() => setNaDialogOpen(true)}
className="gap-1.5 text-muted-foreground hover:text-foreground"
title="Mark as N/A"
>
<Ban className="h-3.5 w-3.5" /> N/A
</Button>
)}
<Button
size="sm"
variant={noteOpen ? 'secondary' : 'ghost'}
onClick={() => setNoteOpen((o) => !o)}
className="gap-1.5"
>
<MessageSquare className="h-3.5 w-3.5" />
Notes{notes.length > 0 ? ` (${notes.length})` : ''}
</Button>
</div>
</div>
{/* Completion Dialog */}
<Dialog open={completeDialogOpen} onOpenChange={setCompleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Complete Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
<div className="flex gap-2">
<Button
size="sm"
variant={imageRightFiled === true ? 'default' : 'outline'}
onClick={() => setImageRightFiled(true)}
>
Yes
</Button>
<Button
size="sm"
variant={imageRightFiled === false ? 'default' : 'outline'}
onClick={() => setImageRightFiled(false)}
>
No
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Set a reminder? <span className="text-muted-foreground font-normal">(optional)</span></p>
<Input
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-48"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCompleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleConfirmComplete} disabled={imageRightFiled === null || completeSubmitting}>
{completeSubmitting ? 'Saving...' : 'Mark Complete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* N/A Dialog */}
<Dialog open={naDialogOpen} onOpenChange={setNaDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark as N/A</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">Please provide a reason why this task is not applicable.</p>
<Textarea
value={naReason}
onChange={(e) => setNaReason(e.target.value)}
placeholder="Enter reason (required)..."
rows={3}
className="mt-2"
/>
<DialogFooter>
<Button variant="outline" onClick={() => { setNaDialogOpen(false); setNaReason('') }}>Cancel</Button>
<Button onClick={handleMarkNA} disabled={!naReason.trim() || naSubmitting}>
{naSubmitting ? 'Saving...' : 'Confirm N/A'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Notes panel */}
{noteOpen && (
<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={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Add a note..."
rows={2}
className="text-sm"
/>
<div className="flex items-center gap-2 flex-wrap">
<Select value={noteStatus} onValueChange={setNoteStatus}>
<SelectTrigger className="w-[160px] h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
</SelectContent>
</Select>
<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>
)}
</div>
)
}
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 [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
const [viewingUserId, setViewingUserId] = useState(currentUserId)
const [loadingTasks, setLoadingTasks] = useState(false)
const [clientFilter, setClientFilter] = useState('')
const [clientSearch, setClientSearch] = useState('')
const [clientOptions, setClientOptions] = useState<{id: string; name: string}[]>([])
const [clientSearchOpen, setClientSearchOpen] = useState(false)
const [sortKey, setSortKey] = useState<'client' | 'date' | 'type' | 'priority'>('date')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [typeFilter, setTypeFilter] = useState<'all' | 'Group' | 'Policy' | 'Client'>('all')
const [priorityFilter, setPriorityFilter] = useState<'all' | 'HIGH' | 'MEDIUM' | 'LOW'>('all')
const viewingUser = users.find((u) => u.id === viewingUserId)
const isViewingSelf = viewingUserId === currentUserId
const selectedClient = clientOptions.find((c) => c.id === clientFilter)
const fetchTasks = useCallback(async (uid: string, cid?: string) => {
setLoadingTasks(true)
try {
const params = new URLSearchParams({ limit: '1000' })
params.set('userId', uid)
if (cid) params.set('clientId', cid)
const res = await fetch(`/api/tasks?${params}`)
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setTasks(data.tasks || [])
} catch (err: any) {
toast.error(err.message || 'Failed to load tasks')
} finally {
setLoadingTasks(false)
}
}, [])
const handleUserChange = (uid: string) => {
setViewingUserId(uid)
fetchTasks(uid, clientFilter || undefined)
}
const handleClientFilterChange = (cid: string) => {
setClientFilter(cid)
fetchTasks(viewingUserId, cid || undefined)
setClientSearchOpen(false)
setClientSearch('')
}
useEffect(() => {
if (!clientSearch.trim()) { setClientOptions([]); return }
const t = setTimeout(async () => {
try {
const res = await fetch(`/api/clients?search=${encodeURIComponent(clientSearch)}&limit=20`)
const data = await res.json()
setClientOptions((data.clients || []).map((c: any) => ({ id: c.id, name: c.name })))
} catch {}
}, 250)
return () => clearTimeout(t)
}, [clientSearch])
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 {
fetchTasks(viewingUserId, clientFilter || undefined)
}
} catch (err: any) {
toast.error(err.message || 'Transfer failed')
} finally {
setTransferring(false)
}
}
const now = new Date()
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 isTerminal = (s: string) => s === 'COMPLETED' || s === 'NA' || s === 'CANCELLED'
const overdue = tasks.filter((t) => new Date(t.dueDate) < now && !isTerminal(t.status))
const dueToday = tasks.filter((t) => { const d = new Date(t.dueDate); return d >= now && d <= week1End && !isTerminal(t.status) })
const due1Week = tasks.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week2End && !isTerminal(t.status) })
const due2Weeks = tasks.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week3End && !isTerminal(t.status) })
const applyCardFilter = (base: Task[]) => {
if (!cardFilter) return base
if (cardFilter === 'overdue') return base.filter((t) => new Date(t.dueDate) < now && !isTerminal(t.status))
if (cardFilter === 'today') return base.filter((t) => { const d = new Date(t.dueDate); return d >= now && d <= week1End && !isTerminal(t.status) })
if (cardFilter === '1week') return base.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week2End && !isTerminal(t.status) })
if (cardFilter === '2weeks') return base.filter((t) => { const d = new Date(t.dueDate); return d > week1End && d <= week3End && !isTerminal(t.status) })
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 taskType = (t: Task): 'Group' | 'Policy' | 'Client' =>
t.policyGroup ? 'Group' : t.policy ? 'Policy' : 'Client'
const priorityOrder: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const handleSort = (key: typeof sortKey) => {
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
else { setSortKey(key); setSortDir('asc') }
}
const baseVisible =
filter === 'assigned' ? tasks.filter((t) => !isTerminal(t.status)) :
filter === 'completed' ? tasks.filter((t) => t.status === 'COMPLETED') :
tasks
const afterCardFilter = applyCardFilter(baseVisible)
const afterColumnFilters = afterCardFilter.filter((t) => {
if (typeFilter !== 'all' && taskType(t) !== typeFilter) return false
if (priorityFilter !== 'all' && t.priority !== priorityFilter) return false
return true
})
const visibleTasks = [...afterColumnFilters].sort((a, b) => {
let cmp = 0
switch (sortKey) {
case 'client':
cmp = (a.client?.name ?? '').localeCompare(b.client?.name ?? '')
break
case 'date':
cmp = new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
break
case 'type': {
const typeOrder: Record<string, number> = { Group: 0, Policy: 1, Client: 2 }
cmp = (typeOrder[taskType(a)] ?? 3) - (typeOrder[taskType(b)] ?? 3)
break
}
case 'priority':
cmp = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9)
break
}
return sortDir === 'asc' ? cmp : -cmp
})
const handleCardFilter = (f: typeof cardFilter) => {
setCardFilter((prev) => prev === f ? null : f)
}
return (
<div className="space-y-8">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
{!isViewingSelf && <Eye className="h-7 w-7 text-muted-foreground" />}
{isViewingSelf ? 'My Tasks' : `${viewingUser?.displayName ?? viewingUser?.email ?? 'Employee'}'s Tasks`}
</h1>
<p className="text-muted-foreground mt-1">
{isViewingSelf ? 'Manage your policy renewal tasks and assignments' : 'Viewing as another employee'}
</p>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
className="gap-1.5"
onClick={() => setAdditionalServiceOpen(true)}
>
<Plus className="h-4 w-4" /> Additional Service
</Button>
{isPrivileged && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground whitespace-nowrap">Viewing as:</span>
<Select value={viewingUserId} onValueChange={handleUserChange}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<UserSelectContent
users={users}
excludeIds={[currentUserId]}
prefixItem={<SelectItem value={currentUserId}>Myself</SelectItem>}
/>
</Select>
</div>
)}
</div>
</div>
{/* Client filter */}
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by client..."
value={clientFilter ? (selectedClient?.name ?? '') : clientSearch}
onChange={(e) => {
setClientSearch(e.target.value)
if (!e.target.value) { setClientFilter(''); fetchTasks(viewingUserId) }
setClientSearchOpen(true)
}}
onFocus={() => { if (!clientFilter) setClientSearchOpen(true) }}
className="pl-9 pr-8"
readOnly={!!clientFilter}
/>
{clientFilter && (
<button
onClick={() => { setClientFilter(''); setClientSearch(''); fetchTasks(viewingUserId) }}
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{clientSearchOpen && clientOptions.length > 0 && !clientFilter && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
{clientOptions.map((c) => (
<button
key={c.id}
onClick={() => handleClientFilterChange(c.id)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-muted text-left"
>
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{c.name}
</button>
))}
</div>
)}
</div>
{/* 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">Past due</p>
</CardContent>
</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">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">Days 17</p>
</CardContent>
</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">1 Week Out</CardTitle>
<CheckSquare className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<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>
{/* Sort / filter / status header */}
<div className="px-6 pt-5 pb-3">
<div className="flex items-center gap-3 flex-wrap rounded-md border border-border bg-muted/30 px-4 py-2.5 text-sm">
{/* Sort buttons */}
{([
['client', 'Client'],
['date', 'Date'],
['type', 'Type'],
['priority', 'Level'],
] as const).map(([key, label]) => (
<button
key={key}
onClick={() => handleSort(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded transition-colors font-medium ${
sortKey === key
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{label}
{sortKey === key ? (
sortDir === 'asc' ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />
) : (
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
))}
{/* Spacer pushes right side */}
<span className="flex-1" />
{/* Type filter */}
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as any)}>
<SelectTrigger className="h-8 w-[120px] text-sm border-border bg-background/50 px-3 gap-1.5">
<Filter className="h-3.5 w-3.5 opacity-60 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="Group">Group</SelectItem>
<SelectItem value="Policy">Policy</SelectItem>
<SelectItem value="Client">Client</SelectItem>
</SelectContent>
</Select>
{/* Priority filter */}
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as any)}>
<SelectTrigger className="h-8 w-[130px] text-sm border-border bg-background/50 px-3 gap-1.5">
<Filter className="h-3.5 w-3.5 opacity-60 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="HIGH">High</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem>
<SelectItem value="LOW">Low</SelectItem>
</SelectContent>
</Select>
{(typeFilter !== 'all' || priorityFilter !== 'all') && (
<button
onClick={() => { setTypeFilter('all'); setPriorityFilter('all') }}
className="flex items-center gap-1 px-2.5 py-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="h-3.5 w-3.5" /> Clear
</button>
)}
<span className="mx-1 h-5 w-px bg-border" />
{/* Transfer button */}
{isPrivileged && visibleTasks.length > 0 && (
<Button
size="sm"
variant="outline"
className="gap-1.5 h-8 text-sm"
onClick={() => setTransferOpen(true)}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
Transfer{cardFilter ? ` ${visibleTasks.length}` : ''}
</Button>
)}
{/* Assigned / Completed / All */}
<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>
</div>
<CardContent>
{visibleTasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No tasks found</p>
<p className="text-sm mt-2">
{typeFilter !== 'all' || priorityFilter !== 'all'
? 'Try adjusting the filters above'
: 'Tasks will appear here when they are assigned to you'}
</p>
</div>
) : (
<div className="space-y-3">
{visibleTasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
)}
</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>
<UserSelectContent users={users} excludeIds={[viewingUserId]} />
</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>
<AdditionalServiceModal
open={additionalServiceOpen}
onOpenChange={setAdditionalServiceOpen}
currentUserId={currentUserId}
onCreated={() => fetchTasks(viewingUserId, clientFilter || undefined)}
/>
</div>
)
}