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)
1108 lines
46 KiB
TypeScript
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>
|
|
)
|
|
}
|