diff --git a/ondeck/src/app/api/clients/[id]/tasks/route.ts b/ondeck/src/app/api/clients/[id]/tasks/route.ts index 338c3c9..abba3ae 100644 --- a/ondeck/src/app/api/clients/[id]/tasks/route.ts +++ b/ondeck/src/app/api/clients/[id]/tasks/route.ts @@ -54,6 +54,23 @@ export async function GET( user: { select: { id: true, displayName: true, email: true } }, }, }, + policy: { + select: { + id: true, + policyNumber: true, + policyType: true, + expirationDate: true, + carrierName: true, + writingCompanyName: true, + }, + }, + policyGroup: { + select: { id: true, name: true, renewalDate: true }, + }, + taskNotes: { + include: { user: { select: { id: true, displayName: true, email: true } } }, + orderBy: { createdAt: 'asc' }, + }, }, orderBy: { dueDate: 'asc' }, }) diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index c70b2ca..bbb36d0 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -35,7 +35,7 @@ import { Combobox, type ComboboxGroup } from '@/components/ui/combobox' import { formatDate, formatRenewalDate } from '@/lib/utils' import { PolicyGroupManager } from '@/components/clients/policy-group-manager' import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal' -import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal' +import { TaskCard } from '@/components/tasks/task-card' import { toast } from 'sonner' interface SimpleUser { @@ -79,7 +79,6 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic const [showCompleted, setShowCompleted] = useState(false) const [notesExpanded, setNotesExpanded] = useState(!!(client.notes)) const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false) - const [editingTaskId, setEditingTaskId] = useState(null) const [sessionUserId, setSessionUserId] = useState('') useEffect(() => { @@ -667,114 +666,16 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic ) : ( - sorted.map((task: any) => { - const completed = isTerminal(task.status) - const overdue = new Date(task.dueDate) < new Date() && !completed - return ( - - -
-
-
-

- {task.title} -

- - {task.status.replace('_', ' ')} - - - {task.priority} - - -
- {/* Always show Policy #, Type, Renewal Date */} -
- - Policy #:{' '} - {task.policy?.policyNumber || '—'} - - - Type:{' '} - {task.policy?.policyType || '—'} - - - Renewal:{' '} - - {task.policy?.expirationDate ? formatRenewalDate(task.policy.expirationDate) : '—'} - - -
- {task.description && ( -

{task.description}

- )} - {task.assignments?.length > 0 && ( -

- Assigned to: {task.assignments.map((a: any) => a.user.displayName || a.user.email).join(', ')} -

- )} -
-
- -

- Due: {formatDate(task.dueDate)} -

-
-
-
-
- ) - }) + sorted.map((task: any) => ( + refreshTasks()} + /> + )) ) })()} - {/* Task edit modal */} - {editingTaskId && (() => { - const editTask = tasks.find((t: any) => t.id === editingTaskId) - if (!editTask) return null - return ( - { if (!open) setEditingTaskId(null) }} - task={{ - ...editTask, - clientId: client.id, - client: { id: client.id, name: client.name }, - } as EditableTask} - onUpdated={() => { - setEditingTaskId(null) - refreshTasks() - }} - /> - ) - })()} = { + 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 ( + + {status.replace('_', ' ')} + + ) +} + +function PriorityDot({ priority }: { priority: string }) { + const map: Record = { + HIGH: 'text-red-500', + MEDIUM: 'text-yellow-500', + LOW: 'text-green-500', + } + return ( + + {priority} + + ) +} + +interface TaskCardProps { + task: TaskCardTask + onUpdated?: (updated: Partial) => void + showClient?: boolean +} + +export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskCardProps) { + const [task, setTask] = useState(initial) + const [notes, setNotes] = useState(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 [editOpen, setEditOpen] = useState(false) + const [statusUpdating, setStatusUpdating] = useState(false) + const [groupPoliciesOpen, setGroupPoliciesOpen] = useState(false) + const [groupPolicies, setGroupPolicies] = useState([]) + const [groupPoliciesLoading, setGroupPoliciesLoading] = useState(false) + const groupPopoverRef = useRef(null) + const [completeDialogOpen, setCompleteDialogOpen] = useState(false) + const [imageRightFiled, setImageRightFiled] = useState(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 update = (patch: Partial) => { + setTask((t) => ({ ...t, ...patch })) + onUpdated?.(patch) + } + + 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) + update({ 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) + update({ 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) + update({ 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) update({ status: noteStatus }) + setNewNote('') + toast.success('Note added') + } catch (err: any) { + toast.error(err.message) + } finally { + setSaving(false) + } + } + + const handleQuickStatus = async (newStatus: string) => { + if (newStatus === task.status) return + if (newStatus === 'NA') { setNaDialogOpen(true); return } + if (newStatus === 'COMPLETED') { handleToggleStatus(); return } + setStatusUpdating(true) + try { + const res = await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }) + if (!res.ok) throw new Error((await res.json()).error) + update({ status: newStatus }) + toast.success(`Status → ${newStatus.replace('_', ' ')}`) + } catch (err: any) { + toast.error(err.message) + } finally { + setStatusUpdating(false) + } + } + + const handleGroupClick = async () => { + if (groupPoliciesOpen) { setGroupPoliciesOpen(false); return } + if (groupPolicies.length === 0 && task.policyGroup?.id) { + setGroupPoliciesLoading(true) + try { + const res = await fetch(`/api/policy-groups/${task.policyGroup.id}`) + if (res.ok) { + const data = await res.json() + setGroupPolicies(data.policies || []) + } + } catch {} + finally { setGroupPoliciesLoading(false) } + } + setGroupPoliciesOpen(true) + } + + const dueMs = new Date(task.dueDate).getTime() + const nowMs = now.getTime() + const daysUntilDue = Math.ceil((dueMs - nowMs) / 86400000) + const dueBg = isCompleted + ? 'bg-muted/50' + : daysUntilDue < 0 + ? 'bg-red-100 dark:bg-red-900' + : daysUntilDue <= 7 + ? 'bg-red-50 dark:bg-red-950' + : daysUntilDue <= 14 + ? 'bg-orange-50 dark:bg-orange-950' + : 'bg-muted/40' + + const levelLabel = task.policyGroup ? 'Group' : task.policy ? 'Policy' : 'Client' + + return ( +
+
+ {/* Left: main content */} +
+
+ +

+ {task.title} +

+ {task.isAdHoc && ( + Ad hoc + )} +
+ {showClient && task.client && ( + + {task.client.name} + + )} +
+ Policy # {task.policy?.policyNumber || '—'} + Type {task.policy?.policyType || '—'} + Renewal {task.policyGroup?.renewalDate ? formatDate(task.policyGroup.renewalDate) : task.policy?.expirationDate ? formatRenewalDate(task.policy.expirationDate) : '—'} +
+ {task.policyGroup && ( +
+ + {groupPoliciesOpen && ( +
+
+ Policies in group + +
+ {groupPoliciesLoading ? ( +
Loading...
+ ) : groupPolicies.length === 0 ? ( +
No policies in this group.
+ ) : ( +
+ {groupPolicies.map((p: any) => ( +
+
{p.policyNumber || '—'} · {p.policyType || '—'}
+
+ {p.carrierName || 'Unknown carrier'} + {p.expirationDate && · Renews {formatRenewalDate(p.expirationDate)}} +
+
+ ))} +
+ )} +
+ )} +
+ )} + {(task.policy?.carrierName || task.policy?.writingCompanyName) && ( +
+ {task.policy?.carrierName && ( + Carrier {task.policy.carrierName} + )} + {task.policy?.writingCompanyName && ( + Writing Co {task.policy.writingCompanyName} + )} +
+ )} + {task.assignments.length > 0 && ( +

+ Assigned to {task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')} +

+ )} +
+ + {/* Right sidebar: grid actions */} +
+ {/* Row 1: Due date | Complete */} +
+ {formatDate(task.dueDate)} +
+ + + + + +

{isCompleted ? 'Reopen task' : 'Mark complete'}

+
+
+ {/* Row 2: Status | N/A */} +
+ +
+ {task.status !== 'COMPLETED' && task.status !== 'NA' && task.status !== 'CANCELLED' ? ( + + + + + +

Mark as N/A

+
+
+ ) : ( +
+ )} + {/* Row 3: Level | Edit */} +
+ {levelLabel} +
+ + + + + +

Edit task

+
+
+ {/* Row 4: Notes */} + +
+
+ + {/* Complete Dialog */} + + + Complete Task +
+
+

Did you file in ImageRight?

+
+ + +
+
+
+

Set a reminder? (optional)

+ setReminderDate(e.target.value)} min={new Date().toISOString().split('T')[0]} className="w-48" /> +
+
+ + + + +
+
+ + {/* N/A Dialog */} + + + Mark as N/A +

Please provide a reason why this task is not applicable.

+