seubert-claims/ondeck/src/components/clients/client-detail.tsx
lorentz 6dd7e3e836 fix(tasks): timezone consistency, task provenance, setup N/A, and task-generation fixes
Timezone (all users EST):
- lib/utils: hardcode APP_TIME_ZONE (America/New_York) in formatDate/
  formatRenewalDate, add formatDateTime and todayInAppTimeZone helpers
- Replace every ad-hoc toLocaleDateString/toLocaleString call across the app
  (task notes, audit log, backups, shape import, renewal groups, client
  detail) with the shared EST-aware helpers
- Fix UTC "today" bug in date-input defaults/min/max (completion date,
  reminder date) that rolled to the next calendar day after ~7-8pm ET

Task provenance:
- Task card info icon (now visible to all users, not just privileged) shows
  full origin: template, level, renewal anchor, due-date math, and who/what
  generated the task
- Include template.daysOffset and creator in task queries

Setup N/A:
- New setupNaAt/setupNaReason fields on Client; mark/restore UI and API to
  exclude non-Shape/lost-business clients from the setup queue everywhere
  it's counted (queue page, API, manager dashboard, metrics gauge)

Task generation fixes:
- auto-generate: client-level branch now gated on the renewal anchor's
  (group/policy) createdAt instead of the client's, so pre-go-live clients
  with new post-go-live groups are no longer skipped forever
- New auto-assign-tasks sweep run after every sync to assign advocates to
  tasks created by paths that don't assign directly (e.g. setup wizard)
2026-07-16 22:32:04 +00:00

1108 lines
46 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight, Settings2, MessageSquare, FileSearch } from 'lucide-react'
import { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
import { formatDate, formatRenewalDate, formatDateTime, daysUntil } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { TaskCard } from '@/components/tasks/task-card'
import { TaskAuditPanel } from '@/components/clients/task-audit-panel'
import { toast } from 'sonner'
const SHAPE_DESIGNATION_NAMES = ['shape', 'shape 2']
interface SimpleUser {
id: string
displayName: string | null
email: string
department?: string | null
}
interface ClientDetailProps {
client: any
designations: any[]
policyGroups?: any[]
allPolicies?: any[]
canManageGroups?: boolean
canManageSetup?: boolean
setupCompletedAt?: string | null
}
export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false, canManageSetup = false, setupCompletedAt }: ClientDetailProps) {
const isShapeClient = [client.designation?.name, client.designation2?.name]
.filter(Boolean)
.some((name: string) => SHAPE_DESIGNATION_NAMES.includes(name.toLowerCase()))
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
const [notes, setNotes] = useState<string>(client.notes || '')
const [notesSaving, setNotesSaving] = useState(false)
const [notesSavedAt, setNotesSavedAt] = useState<string | null>(null)
const [tasks, setTasks] = useState<any[]>(client.tasks || [])
const [contacts, setContacts] = useState<any[]>([])
const [contactsLoading, setContactsLoading] = useState(false)
const [contactForm, setContactForm] = useState({ label: '', name: '', phone: '', email: '', notes: '' })
const [editingContactId, setEditingContactId] = useState<string | null>(null)
const [contactSaving, setContactSaving] = useState(false)
const [parentClient, setParentClient] = useState<{id:string;name:string}|null>(client.parentClient || null)
const [subsidiaries, setSubsidiaries] = useState<any[]>(client.subsidiaries || [])
const [parentSearch, setParentSearch] = useState('')
const [parentOptions, setParentOptions] = useState<{id:string;name:string}[]>([])
const [parentLinking, setParentLinking] = useState(false)
const [showArchivedTasks, setShowArchivedTasks] = useState(false)
const [subsidiariesModalOpen, setSubsidiariesModalOpen] = useState(false)
const [taskSort, setTaskSort] = useState<'date' | 'priority'>('date')
const [taskSortDir, setTaskSortDir] = useState<'asc' | 'desc'>('asc')
const [showCompleted, setShowCompleted] = useState(false)
const [notesExpanded, setNotesExpanded] = useState(!!(client.notes))
const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
const [sessionUserId, setSessionUserId] = useState('')
useEffect(() => {
fetch('/api/auth/session')
.then((r) => r.json())
.then((s) => setSessionUserId(s?.user?.id ?? ''))
.catch(() => {})
}, [])
const refreshTasks = async (archived = showArchivedTasks) => {
try {
const url = `/api/clients/${client.id}/tasks${archived ? '?archived=true' : ''}`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
}
} catch {}
}
const handleToggleArchived = () => {
const next = !showArchivedTasks
setShowArchivedTasks(next)
refreshTasks(next)
}
// Assignments state
const [allUsers, setAllUsers] = useState<SimpleUser[]>([])
const [claimsUsers, setClaimsUsers] = useState<SimpleUser[]>([])
const [advocateId, setAdvocateId] = useState<string>(client.claimsAdvocate?.id || '')
const [advocateSaving, setAdvocateSaving] = useState(false)
const [reassignDialogOpen, setReassignDialogOpen] = useState(false)
const [members, setMembers] = useState<any[]>(client.members || [])
const [addMemberId, setAddMemberId] = useState<string>('')
useEffect(() => {
fetch('/api/users/staff')
.then((r) => r.json())
.then((d) => setAllUsers(d.users || []))
.catch(() => {})
fetch('/api/users/claims-staff')
.then((r) => r.json())
.then((d) => setClaimsUsers(d.users || []))
.catch(() => {})
}, [])
const doAdvocateSave = async (reassignTasks: boolean) => {
setAdvocateSaving(true)
try {
const res = await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claimsAdvocateId: advocateId || null, reassignTasks }),
})
if (!res.ok) throw new Error()
toast.success(reassignTasks ? 'Advocate updated and tasks reassigned' : 'Claims advocate updated')
} catch {
toast.error('Failed to update advocate')
} finally {
setAdvocateSaving(false)
}
}
const handleAdvocateSave = () => {
const previousAdvocateId = client.claimsAdvocate?.id
const isReplacing = !!previousAdvocateId && advocateId !== previousAdvocateId && !!advocateId
if (isReplacing) {
setReassignDialogOpen(true)
} else {
doAdvocateSave(false)
}
}
const handleAddMember = async () => {
if (!addMemberId) return
try {
const res = await fetch(`/api/clients/${client.id}/team`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: addMemberId }),
})
if (res.status === 409) { toast.error('Already a member'); return }
if (!res.ok) throw new Error()
const member = await res.json()
setMembers((prev) => [...prev, member])
setAddMemberId('')
toast.success('Member added')
} catch {
toast.error('Failed to add member')
}
}
const handleRemoveMember = async (userId: string) => {
try {
const res = await fetch(`/api/clients/${client.id}/team`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
})
if (!res.ok) throw new Error()
setMembers((prev) => prev.filter((m: any) => m.userId !== userId))
toast.success('Member removed')
} catch {
toast.error('Failed to remove member')
}
}
useEffect(() => {
setContactsLoading(true)
fetch(`/api/clients/${client.id}/contacts`)
.then((r) => r.json())
.then((d) => setContacts(Array.isArray(d) ? d : []))
.catch(() => {})
.finally(() => setContactsLoading(false))
}, [client.id])
const resetContactForm = () => setContactForm({ label: '', name: '', phone: '', email: '', notes: '' })
useEffect(() => {
if (!parentSearch.trim() || parentSearch.length < 2) { setParentOptions([]); return }
const t = setTimeout(() => {
fetch(`/api/clients?search=${encodeURIComponent(parentSearch)}&limit=10`)
.then((r) => r.json())
.then((d) => setParentOptions((d.clients ?? []).filter((c: any) => c.id !== client.id)))
.catch(() => {})
}, 300)
return () => clearTimeout(t)
}, [parentSearch, client.id])
const handleLinkParent = async (parentId: string, parentName: string) => {
setParentLinking(true)
try {
const res = await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parentClientId: parentId }),
})
if (!res.ok) throw new Error()
setParentClient({ id: parentId, name: parentName })
setParentSearch('')
setParentOptions([])
toast.success(`Linked to ${parentName}`)
} catch {
toast.error('Failed to link parent')
} finally {
setParentLinking(false)
}
}
const handleUnlinkParent = async () => {
setParentLinking(true)
try {
const res = await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parentClientId: null }),
})
if (!res.ok) throw new Error()
setParentClient(null)
toast.success('Parent unlinked')
} catch {
toast.error('Failed to unlink parent')
} finally {
setParentLinking(false)
}
}
const handleContactSave = async () => {
if (!contactForm.label.trim() || !contactForm.name.trim()) return
setContactSaving(true)
try {
const url = editingContactId
? `/api/clients/${client.id}/contacts/${editingContactId}`
: `/api/clients/${client.id}/contacts`
const method = editingContactId ? 'PATCH' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contactForm),
})
if (!res.ok) throw new Error()
const saved = await res.json()
if (editingContactId) {
setContacts((prev) => prev.map((c) => (c.id === editingContactId ? saved : c)))
} else {
setContacts((prev) => [...prev, saved])
}
resetContactForm()
setEditingContactId(null)
toast.success(editingContactId ? 'Contact updated' : 'Contact added')
} catch {
toast.error('Failed to save contact')
} finally {
setContactSaving(false)
}
}
const handleContactDelete = async (contactId: string) => {
try {
const res = await fetch(`/api/clients/${client.id}/contacts/${contactId}`, { method: 'DELETE' })
if (!res.ok) throw new Error()
setContacts((prev) => prev.filter((c) => c.id !== contactId))
toast.success('Contact removed')
} catch {
toast.error('Failed to remove contact')
}
}
const handleNotesSave = async () => {
setNotesSaving(true)
try {
const res = await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: notes || null }),
})
if (!res.ok) throw new Error()
setNotesSavedAt(formatDateTime(new Date()))
toast.success('Notes saved')
} catch {
toast.error('Failed to save notes')
} finally {
setNotesSaving(false)
}
}
const handleDesignationUpdate = async () => {
setSaving(true)
try {
await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
designationId: selectedDesignation || null,
designation2Id: selectedDesignation2 || null,
}),
})
} catch (error) {
console.error('Failed to update designations:', error)
} finally {
setSaving(false)
}
}
const isParent = subsidiaries.length > 0
const isChild = !!parentClient
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Building2 className="h-8 w-8" />
{client.name}
</h1>
<div className="flex flex-wrap gap-4 mt-3 text-sm text-muted-foreground">
{client.city && client.state && (
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{client.city}, {client.state}
</div>
)}
{client.phone && (
<div className="flex items-center gap-1">
<Phone className="h-4 w-4" />
{client.phone}
</div>
)}
{client.email && (
<div className="flex items-center gap-1">
<Mail className="h-4 w-4" />
{client.email}
</div>
)}
</div>
</div>
{/* Right-side header actions */}
<div className="flex flex-wrap gap-2 shrink-0 pt-1 items-start">
{canManageSetup && (
<Link
href={`/manager/setup/${client.id}`}
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
setupCompletedAt
? 'border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary'
: 'border-orange-400 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:bg-orange-950/20 dark:text-orange-400'
}`}
>
<Settings2 className="h-3.5 w-3.5" />
{setupCompletedAt ? 'Edit Setup' : 'Complete Setup'}
</Link>
)}
{isParent && (
<button
onClick={() => setSubsidiariesModalOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-violet-500/40 bg-violet-500/10 px-3 py-1 text-xs font-medium text-violet-700 dark:text-violet-400 hover:bg-violet-500/20 transition-colors"
>
<GitBranch className="h-3.5 w-3.5" />
Parent · {subsidiaries.length} {subsidiaries.length === 1 ? 'subsidiary' : 'subsidiaries'}
</button>
)}
{isChild && (
<Link
href={`/clients/${parentClient!.id}`}
className="inline-flex items-center gap-1.5 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1 text-xs font-medium text-blue-700 dark:text-blue-400 hover:bg-blue-500/20 transition-colors"
>
<ChevronRight className="h-3.5 w-3.5 rotate-180" />
Child of {parentClient!.name}
</Link>
)}
</div>
</div>
{/* Subsidiaries modal */}
<Dialog open={subsidiariesModalOpen} onOpenChange={setSubsidiariesModalOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="h-5 w-5" />
Subsidiaries of {client.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
{subsidiaries.map((sub: any) => (
<Link
key={sub.id}
href={`/clients/${sub.id}`}
onClick={() => setSubsidiariesModalOpen(false)}
className="flex items-center justify-between rounded-lg border border-border p-3 hover:bg-muted/50 transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm group-hover:text-primary transition-colors truncate">{sub.name}</p>
{sub.claimsAdvocate && (
<p className="text-xs text-muted-foreground mt-0.5">
Advocate: {sub.claimsAdvocate.displayName || sub.claimsAdvocate.email}
</p>
)}
</div>
<div className="flex items-center gap-3 ml-4 shrink-0 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
{sub._count?.policies ?? 0}
</span>
<span className="flex items-center gap-1">
<CheckSquare className="h-3.5 w-3.5" />
{sub._count?.tasks ?? 0}
</span>
<ChevronRight className="h-4 w-4 opacity-40 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
))}
</div>
</DialogContent>
</Dialog>
{/* Client Notes — collapsible */}
<div className="rounded-lg border bg-card">
<button
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium hover:bg-accent/50 transition-colors rounded-lg"
onClick={() => setNotesExpanded((v) => !v)}
>
<div className="flex items-center gap-2 text-muted-foreground">
<StickyNote className="h-4 w-4" />
<span>Client Notes</span>
{notes && !notesExpanded && (
<span className="text-xs text-foreground font-normal truncate max-w-[400px] ml-1 italic">{notes.slice(0, 80)}{notes.length > 80 ? '…' : ''}</span>
)}
{!notes && !notesExpanded && (
<span className="text-xs font-normal text-muted-foreground/60">No notes</span>
)}
</div>
{notesExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{notesExpanded && (
<div className="px-4 pb-4 space-y-3 border-t">
<Textarea
className="mt-3"
placeholder="Add notes about this client (e.g. reporting instructions, special handling)..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
/>
<div className="flex items-center gap-3">
<Button onClick={handleNotesSave} disabled={notesSaving} size="sm">
{notesSaving ? 'Saving...' : 'Save Notes'}
</Button>
{notesSavedAt && (
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
Saved {notesSavedAt}
</span>
)}
</div>
</div>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="policies">
<TabsList className="flex-wrap h-auto">
<TabsTrigger value="policies">
<FileText className="h-4 w-4 mr-2" />
Active Policies ({client.policies.length})
</TabsTrigger>
<TabsTrigger value="tasks">
<CheckSquare className="h-4 w-4 mr-2" />
Tasks ({tasks.filter((t: any) => t.status !== 'COMPLETED' && t.status !== 'NA' && t.status !== 'CANCELLED').length})
</TabsTrigger>
<TabsTrigger value="renewal-groups">
<CalendarRange className="h-4 w-4 mr-2" />
Renewal Groups ({policyGroups.length})
</TabsTrigger>
<TabsTrigger value="contacts">
<Users className="h-4 w-4 mr-2" />
Contacts ({contacts.length})
</TabsTrigger>
<TabsTrigger value="assignment">
<UserCheck className="h-4 w-4 mr-2" />
Assignment
</TabsTrigger>
{isShapeClient && (
<TabsTrigger value="document-audit">
<FileSearch className="h-4 w-4 mr-2" />
Document Audit
</TabsTrigger>
)}
</TabsList>
<TabsContent value="policies" className="space-y-4">
{client.policies.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
No policies found
</CardContent>
</Card>
) : (
client.policies.map((policy: any) => (
<Link key={policy.id} href={`/policies/${policy.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="pt-6">
<div className="space-y-3">
{/* Header with Policy Type and Expiration */}
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-bold">{policy.policyType || 'Policy'}</h3>
<p className="text-sm text-muted-foreground mt-1">
Policy #: {policy.policyNumber || 'N/A'}
</p>
</div>
<div className="flex items-center gap-1.5" suppressHydrationWarning>
{(() => {
const d = daysUntil(policy.expirationDate)
if (d <= 0) return null
if (d <= 30) return <Badge variant="destructive" className="text-xs">{d}d</Badge>
if (d <= 90) return <Badge variant="secondary" className="text-xs">{d}d</Badge>
return null
})()}
<Badge variant={new Date(policy.expirationDate) < new Date() ? 'destructive' : 'default'}>
Renewal {formatRenewalDate(policy.expirationDate)}
</Badge>
</div>
</div>
{/* Policy Details Grid */}
<div className="grid grid-cols-2 gap-4 pt-3 border-t">
{policy.writingCompanyName && (
<div>
<p className="text-xs text-muted-foreground">Writing Company</p>
<p className="text-sm font-medium">{policy.writingCompanyName}</p>
</div>
)}
{policy.department && (
<div>
<p className="text-xs text-muted-foreground">Department</p>
<p className="text-sm font-medium">{policy.department}</p>
</div>
)}
{policy.executiveName && (
<div>
<p className="text-xs text-muted-foreground">Executive</p>
<p className="text-sm font-medium">{policy.executiveName}</p>
</div>
)}
{policy.csrName && (
<div>
<p className="text-xs text-muted-foreground">CSR</p>
<p className="text-sm font-medium">{policy.csrName}</p>
</div>
)}
{policy.billMethod && (
<div>
<p className="text-xs text-muted-foreground">Bill Method</p>
<p className="text-sm font-medium">{policy.billMethod}</p>
</div>
)}
{policy.status && (
<div>
<p className="text-xs text-muted-foreground">Status</p>
<p className="text-sm font-medium">{policy.status}</p>
</div>
)}
</div>
{/* Additional Personnel */}
{(policy.additionalRep1 || policy.additionalRep2 || policy.additionalExec1 || policy.additionalExec2) && (
<div className="pt-3 border-t">
<p className="text-xs text-muted-foreground mb-2">Additional Personnel</p>
<div className="flex flex-wrap gap-2">
{policy.additionalRep1 && (
<Badge variant="outline">Rep: {policy.additionalRep1}</Badge>
)}
{policy.additionalRep2 && (
<Badge variant="outline">Rep: {policy.additionalRep2}</Badge>
)}
{policy.additionalExec1 && (
<Badge variant="outline">Exec: {policy.additionalExec1}</Badge>
)}
{policy.additionalExec2 && (
<Badge variant="outline">Exec: {policy.additionalExec2}</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))
)}
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
{/* Sort / filter header */}
<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 */}
{([['date', 'Date'], ['priority', 'Priority']] as const).map(([key, label]) => (
<button
key={key}
onClick={() => {
if (taskSort === key) setTaskSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
else { setTaskSort(key); setTaskSortDir('asc') }
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded transition-colors font-medium ${
taskSort === key
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{label}
{taskSort === key ? (
taskSortDir === '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>
))}
<span className="flex-1" />
{/* Show completed toggle */}
<button
onClick={() => setShowCompleted((v) => !v)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded transition-colors font-medium ${
showCompleted
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<CheckSquare className="h-3.5 w-3.5" />
{showCompleted ? 'Hide Completed' : 'Show Completed'}
</button>
<span className="mx-1 h-5 w-px bg-border" />
<Button
size="sm"
variant={showArchivedTasks ? 'default' : 'outline'}
className="h-8 text-sm"
onClick={handleToggleArchived}
>
{showArchivedTasks ? 'Hide Archived' : 'Show Archived'}
</Button>
<Button size="sm" className="gap-1.5 h-8 text-sm" onClick={() => setAdditionalServiceOpen(true)}>
<Plus className="h-3.5 w-3.5" /> Additional Service
</Button>
</div>
{(() => {
const isTerminal = (s: string) => s === 'COMPLETED' || s === 'NA' || s === 'CANCELLED'
const priorityOrder: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const filtered = showCompleted ? tasks : tasks.filter((t: any) => !isTerminal(t.status))
const sorted = [...filtered].sort((a: any, b: any) => {
let cmp = 0
if (taskSort === 'date') {
cmp = new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
} else {
cmp = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9)
}
return taskSortDir === 'asc' ? cmp : -cmp
})
return sorted.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
No tasks found
</CardContent>
</Card>
) : (
sorted.map((task: any) => (
<TaskCard
key={task.id}
task={task}
onUpdated={() => refreshTasks()}
isPrivileged={canManageGroups}
/>
))
)
})()}
</TabsContent>
<AdditionalServiceModal
open={additionalServiceOpen}
onOpenChange={setAdditionalServiceOpen}
currentUserId={sessionUserId}
context={{
clientId: client.id,
clientName: client.name,
policies: client.policies?.map((p: any) => ({
id: p.id,
policyNumber: p.policyNumber,
policyType: p.policyType,
})),
policyGroups: policyGroups.map((g: any) => ({
id: g.id,
name: g.name,
renewalDate: g.renewalDate ? formatDate(g.renewalDate) : undefined,
})),
}}
onCreated={() => refreshTasks()}
/>
<TabsContent value="renewal-groups">
<PolicyGroupManager
clientId={client.id}
initialGroups={policyGroups}
allPolicies={allPolicies ?? client.policies}
canManage={canManageGroups}
clientRenewalDate={client.renewalDate ?? null}
onTasksGenerated={() => refreshTasks()}
/>
</TabsContent>
<TabsContent value="contacts" className="space-y-4">
{/* Add / Edit Form */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{editingContactId ? 'Edit Contact' : 'Add Contact'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">Role / Label <span className="text-destructive">*</span></label>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="e.g. Main Contact, Claims Contact"
value={contactForm.label}
onChange={(e) => setContactForm((f) => ({ ...f, label: e.target.value }))}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Name <span className="text-destructive">*</span></label>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="Full name"
value={contactForm.name}
onChange={(e) => setContactForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Phone</label>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="Phone number"
value={contactForm.phone}
onChange={(e) => setContactForm((f) => ({ ...f, phone: e.target.value }))}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Email</label>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="Email address"
value={contactForm.email}
onChange={(e) => setContactForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Notes</label>
<Textarea
placeholder="Additional notes about this contact..."
value={contactForm.notes}
onChange={(e) => setContactForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleContactSave}
disabled={contactSaving || !contactForm.label.trim() || !contactForm.name.trim()}
>
{contactSaving ? 'Saving...' : editingContactId ? 'Update Contact' : 'Add Contact'}
</Button>
{editingContactId && (
<Button size="sm" variant="outline" onClick={() => { resetContactForm(); setEditingContactId(null) }}>
Cancel
</Button>
)}
</div>
</CardContent>
</Card>
{/* Contact List */}
{contactsLoading ? (
<Card><CardContent className="pt-6 text-center text-muted-foreground">Loading...</CardContent></Card>
) : contacts.length === 0 ? (
<Card><CardContent className="pt-6 text-center text-muted-foreground">No contacts added yet</CardContent></Card>
) : (
<div className="grid gap-3 md:grid-cols-2">
{contacts.map((contact: any) => (
<Card key={contact.id}>
<CardContent className="pt-4">
<div className="flex justify-between items-start">
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">{contact.label}</Badge>
{contact.source === 'ams' && <Badge variant="outline" className="text-xs shrink-0">AMS</Badge>}
<span className="font-medium text-sm truncate">{contact.name}</span>
</div>
{contact.title && (
<p className="text-xs text-muted-foreground">{contact.title}</p>
)}
{contact.phone && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Phone className="h-3 w-3" />{contact.phone}
</div>
)}
{contact.mobilePhone && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Phone className="h-3 w-3" />{contact.mobilePhone} (mobile)
</div>
)}
{contact.email && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />{contact.email}
</div>
)}
{contact.notes && (
<p className="text-xs text-muted-foreground mt-1">{contact.notes}</p>
)}
</div>
{contact.source !== 'ams' && (
<div className="flex gap-1 shrink-0 ml-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingContactId(contact.id)
setContactForm({ label: contact.label, name: contact.name, phone: contact.phone || '', email: contact.email || '', notes: contact.notes || '' })
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => handleContactDelete(contact.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="assignment" className="space-y-6">
{/* Claims Advocate */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<UserCheck className="h-4 w-4" />
Claims Advocate
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Combobox
className="flex-1"
options={claimsUsers.map((u) => ({ value: u.id, label: u.displayName || u.email }))}
value={advocateId}
onChange={setAdvocateId}
placeholder="Select advocate (Claims dept)"
emptyText="No Claims staff found"
/>
<Button onClick={handleAdvocateSave} disabled={advocateSaving}>
{advocateSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardContent>
</Card>
{/* Designations */}
<Card>
<CardHeader>
<CardTitle className="text-base">Designations</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium mb-2 block">Primary Designation</label>
<Select value={selectedDesignation || undefined} onValueChange={setSelectedDesignation}>
<SelectTrigger>
<SelectValue placeholder="Select designation" />
</SelectTrigger>
<SelectContent>
{designations.map((designation) => (
<SelectItem key={designation.id} value={designation.id}>
{designation.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Secondary Designation</label>
<Select value={selectedDesignation2 || undefined} onValueChange={setSelectedDesignation2}>
<SelectTrigger>
<SelectValue placeholder="Select designation" />
</SelectTrigger>
<SelectContent>
{designations.map((designation) => (
<SelectItem key={designation.id} value={designation.id}>
{designation.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button onClick={handleDesignationUpdate} disabled={saving}>
{saving ? 'Saving...' : 'Update Designations'}
</Button>
</CardContent>
</Card>
{/* Team Members */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" />
Team Members
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{members.length === 0 && (
<p className="text-xs text-muted-foreground">No members assigned</p>
)}
{members.map((m: any) => (
<div key={m.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{m.user?.displayName || m.user?.email}</span>
</div>
<Button variant="ghost" size="icon" onClick={() => handleRemoveMember(m.userId)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex gap-2 pt-1">
<Combobox
className="flex-1"
groups={(() => {
const available = allUsers.filter((u) => !members.some((m: any) => m.userId === u.id))
const toOption = (u: SimpleUser) => ({ value: u.id, label: u.displayName || u.email })
const claims = available.filter((u) => u.department?.toLowerCase().includes('claims'))
const others = available.filter((u) => !u.department?.toLowerCase().includes('claims'))
const deptMap = new Map<string, SimpleUser[]>()
others.forEach((u) => {
const dept = u.department || 'Other'
deptMap.set(dept, [...(deptMap.get(dept) ?? []), u])
})
const groups: ComboboxGroup[] = []
if (claims.length) groups.push({ label: 'Claims', options: claims.map(toOption), defaultExpanded: true })
deptMap.forEach((users, dept) => groups.push({ label: dept, options: users.map(toOption), defaultExpanded: false }))
return groups
})()}
value={addMemberId}
onChange={setAddMemberId}
placeholder="Add member..."
/>
<Button variant="outline" onClick={handleAddMember} disabled={!addMemberId}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Related Companies */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building2 className="h-4 w-4" />
Related Companies
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Parent Company</p>
{parentClient ? (
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<a href={`/clients/${parentClient.id}`} className="text-sm font-medium hover:underline">
{parentClient.name}
</a>
<Button
size="sm" variant="ghost"
className="text-destructive hover:text-destructive h-7 px-2"
onClick={handleUnlinkParent} disabled={parentLinking}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="space-y-2">
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="Search for parent company..."
value={parentSearch}
onChange={(e) => setParentSearch(e.target.value)}
/>
{parentOptions.length > 0 && (
<div className="rounded-md border bg-popover shadow-md">
{parentOptions.map((opt) => (
<button
key={opt.id}
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
onClick={() => handleLinkParent(opt.id, opt.name)}
disabled={parentLinking}
>
{opt.name}
</button>
))}
</div>
)}
</div>
)}
</div>
{subsidiaries.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Subsidiaries ({subsidiaries.length})</p>
<div className="space-y-1">
{subsidiaries.map((sub: any) => (
<div key={sub.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<a href={`/clients/${sub.id}`} className="text-sm font-medium hover:underline">
{sub.name}
</a>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>{sub._count?.policies ?? 0} policies</span>
<span>{sub._count?.tasks ?? 0} tasks</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{isShapeClient && (
<TabsContent value="document-audit" className="space-y-4">
<TaskAuditPanel clientId={client.id} />
</TabsContent>
)}
</Tabs>
{/* Advocate reassignment dialog */}
<AlertDialog open={reassignDialogOpen} onOpenChange={setReassignDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reassign open tasks?</AlertDialogTitle>
<AlertDialogDescription>
Do you want to reassign all open tasks currently assigned to the previous advocate to the new advocate?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => { setReassignDialogOpen(false); doAdvocateSave(false) }}>
No, change advocate only
</AlertDialogCancel>
<AlertDialogAction onClick={() => { setReassignDialogOpen(false); doAdvocateSave(true) }}>
Yes, reassign tasks too
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}