966 lines
38 KiB
TypeScript
966 lines
38 KiB
TypeScript
'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 1–7</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 8–14</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 8–21</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>
|
||
)
|
||
}
|