feat: pre-launch fixes - renewal date, task suppression, active policies, contacts, N/A enforcement, completion prompts, task groups, client notes, parent/subsidiary linking
This commit is contained in:
parent
0ac872a4da
commit
9dd30de358
14 changed files with 791 additions and 29 deletions
|
|
@ -122,6 +122,7 @@ model Client {
|
||||||
designationId String? @map("designation_id")
|
designationId String? @map("designation_id")
|
||||||
designation2Id String? @map("designation2_id")
|
designation2Id String? @map("designation2_id")
|
||||||
claimsAdvocateId String? @map("claims_advocate_id")
|
claimsAdvocateId String? @map("claims_advocate_id")
|
||||||
|
parentClientId String? @map("parent_client_id")
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
customFields Json @default("{}") @map("custom_fields")
|
customFields Json @default("{}") @map("custom_fields")
|
||||||
lastSyncedAt DateTime? @map("last_synced_at")
|
lastSyncedAt DateTime? @map("last_synced_at")
|
||||||
|
|
@ -131,18 +132,39 @@ model Client {
|
||||||
designation Designation? @relation("ClientDesignation1", fields: [designationId], references: [id])
|
designation Designation? @relation("ClientDesignation1", fields: [designationId], references: [id])
|
||||||
designation2 Designation? @relation("ClientDesignation2", fields: [designation2Id], references: [id])
|
designation2 Designation? @relation("ClientDesignation2", fields: [designation2Id], references: [id])
|
||||||
claimsAdvocate User? @relation("ClientAdvocate", fields: [claimsAdvocateId], references: [id])
|
claimsAdvocate User? @relation("ClientAdvocate", fields: [claimsAdvocateId], references: [id])
|
||||||
|
parentClient Client? @relation("ClientParent", fields: [parentClientId], references: [id])
|
||||||
|
subsidiaries Client[] @relation("ClientParent")
|
||||||
policies Policy[]
|
policies Policy[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
policyGroups PolicyGroup[]
|
policyGroups PolicyGroup[]
|
||||||
members ClientMember[]
|
members ClientMember[]
|
||||||
|
contacts ClientContact[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([designationId])
|
@@index([designationId])
|
||||||
@@index([designation2Id])
|
@@index([designation2Id])
|
||||||
@@index([claimsAdvocateId])
|
@@index([claimsAdvocateId])
|
||||||
|
@@index([parentClientId])
|
||||||
@@map("clients")
|
@@map("clients")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ClientContact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
clientId String @map("client_id")
|
||||||
|
label String
|
||||||
|
name String
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([clientId])
|
||||||
|
@@map("client_contacts")
|
||||||
|
}
|
||||||
|
|
||||||
model ClientMember {
|
model ClientMember {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
clientId String @map("client_id")
|
clientId String @map("client_id")
|
||||||
|
|
@ -258,6 +280,7 @@ model TaskTemplate {
|
||||||
timing TaskTiming
|
timing TaskTiming
|
||||||
daysOffset Int @map("days_offset")
|
daysOffset Int @map("days_offset")
|
||||||
defaultPriority TaskPriority @map("default_priority")
|
defaultPriority TaskPriority @map("default_priority")
|
||||||
|
taskGroup String? @map("task_group")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
displayOrder Int? @map("display_order")
|
displayOrder Int? @map("display_order")
|
||||||
designationId String? @map("designation_id")
|
designationId String? @map("designation_id")
|
||||||
|
|
@ -294,6 +317,9 @@ model Task {
|
||||||
isAdHoc Boolean @default(false) @map("is_ad_hoc")
|
isAdHoc Boolean @default(false) @map("is_ad_hoc")
|
||||||
naReason String? @map("na_reason") @db.Text
|
naReason String? @map("na_reason") @db.Text
|
||||||
cancelledReason String? @map("cancelled_reason") @db.Text
|
cancelledReason String? @map("cancelled_reason") @db.Text
|
||||||
|
taskGroup String? @map("task_group")
|
||||||
|
imageRightFiled Boolean? @map("image_right_filed")
|
||||||
|
reminderDate DateTime? @map("reminder_date")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default async function ClientDetailPage({
|
||||||
const userRoles = (session.user as any).roles || []
|
const userRoles = (session.user as any).roles || []
|
||||||
const canManageGroups = userRoles.includes('Admin') || userRoles.includes('Manager')
|
const canManageGroups = userRoles.includes('Admin') || userRoles.includes('Manager')
|
||||||
|
|
||||||
const [client, designations, policyGroups] = await Promise.all([
|
const [client, designations, policyGroups, allPolicies] = await Promise.all([
|
||||||
prisma.client.findUnique({
|
prisma.client.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -32,6 +32,17 @@ export default async function ClientDetailPage({
|
||||||
claimsAdvocate: {
|
claimsAdvocate: {
|
||||||
select: { id: true, displayName: true, email: true },
|
select: { id: true, displayName: true, email: true },
|
||||||
},
|
},
|
||||||
|
parentClient: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
subsidiaries: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
_count: { select: { tasks: true, policies: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
},
|
||||||
members: {
|
members: {
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, displayName: true, email: true } },
|
user: { select: { id: true, displayName: true, email: true } },
|
||||||
|
|
@ -39,9 +50,24 @@ export default async function ClientDetailPage({
|
||||||
orderBy: { createdAt: 'asc' as const },
|
orderBy: { createdAt: 'asc' as const },
|
||||||
},
|
},
|
||||||
policies: {
|
policies: {
|
||||||
|
where: { status: 'Active' },
|
||||||
orderBy: { expirationDate: 'desc' },
|
orderBy: { expirationDate: 'desc' },
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
|
where: (() => {
|
||||||
|
const cutoff = new Date()
|
||||||
|
cutoff.setDate(cutoff.getDate() - 7)
|
||||||
|
return {
|
||||||
|
NOT: [
|
||||||
|
{ policy: { status: { in: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] } } },
|
||||||
|
{ policyGroup: { renewalDate: { lt: cutoff } } },
|
||||||
|
],
|
||||||
|
OR: [
|
||||||
|
{ dueDate: { gte: cutoff } },
|
||||||
|
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})(),
|
||||||
include: {
|
include: {
|
||||||
assignments: {
|
assignments: {
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -83,6 +109,21 @@ export default async function ClientDetailPage({
|
||||||
},
|
},
|
||||||
orderBy: { renewalDate: 'asc' },
|
orderBy: { renewalDate: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
prisma.policy.findMany({
|
||||||
|
where: { clientId: id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
policyNumber: true,
|
||||||
|
policyType: true,
|
||||||
|
expirationDate: true,
|
||||||
|
department: true,
|
||||||
|
carrierName: true,
|
||||||
|
writingCompanyName: true,
|
||||||
|
policyGroupId: true,
|
||||||
|
premiumAmount: true,
|
||||||
|
},
|
||||||
|
orderBy: { expirationDate: 'desc' },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
@ -98,12 +139,18 @@ export default async function ClientDetailPage({
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allPoliciesData = allPolicies.map(policy => ({
|
||||||
|
...policy,
|
||||||
|
premiumAmount: policy.premiumAmount ? Number(policy.premiumAmount) : null,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8">
|
<div className="container mx-auto py-8">
|
||||||
<ClientDetail
|
<ClientDetail
|
||||||
client={clientData}
|
client={clientData}
|
||||||
designations={designations}
|
designations={designations}
|
||||||
policyGroups={policyGroups}
|
policyGroups={policyGroups}
|
||||||
|
allPolicies={allPoliciesData}
|
||||||
canManageGroups={canManageGroups}
|
canManageGroups={canManageGroups}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus } from 'lucide-react'
|
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban } from 'lucide-react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
|
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
|
||||||
|
|
@ -93,24 +93,36 @@ function TaskCard({ task: initial }: { task: Task }) {
|
||||||
const [noteStatus, setNoteStatus] = useState(initial.status)
|
const [noteStatus, setNoteStatus] = useState(initial.status)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [toggling, setToggling] = 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 now = new Date()
|
||||||
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
|
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
|
||||||
const isCompleted = task.status === 'COMPLETED'
|
const isCompleted = task.status === 'COMPLETED'
|
||||||
|
|
||||||
const handleToggleStatus = async () => {
|
const handleToggleStatus = async () => {
|
||||||
|
if (!isCompleted) {
|
||||||
|
setImageRightFiled(null)
|
||||||
|
setReminderDate('')
|
||||||
|
setCompleteDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
setToggling(true)
|
setToggling(true)
|
||||||
try {
|
try {
|
||||||
const newStatus = isCompleted ? 'NOT_STARTED' : 'COMPLETED'
|
|
||||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify({ status: 'NOT_STARTED' }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error((await res.json()).error)
|
if (!res.ok) throw new Error((await res.json()).error)
|
||||||
setTask((t) => ({ ...t, status: newStatus }))
|
setTask((t) => ({ ...t, status: 'NOT_STARTED' }))
|
||||||
setNoteStatus(newStatus)
|
setNoteStatus('NOT_STARTED')
|
||||||
toast.success(newStatus === 'COMPLETED' ? 'Task marked complete' : 'Task reopened')
|
toast.success('Task reopened')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.message)
|
toast.error(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -118,6 +130,51 @@ function TaskCard({ task: initial }: { task: Task }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const handleAddNote = async () => {
|
||||||
if (!newNote.trim()) return
|
if (!newNote.trim()) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -231,6 +288,17 @@ function TaskCard({ task: initial }: { task: Task }) {
|
||||||
: <><CheckCircle2 className="h-3.5 w-3.5" /> Complete</>
|
: <><CheckCircle2 className="h-3.5 w-3.5" /> Complete</>
|
||||||
}
|
}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={noteOpen ? 'secondary' : 'ghost'}
|
variant={noteOpen ? 'secondary' : 'ghost'}
|
||||||
|
|
@ -243,6 +311,75 @@ function TaskCard({ task: initial }: { task: Task }) {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Notes panel */}
|
||||||
{noteOpen && (
|
{noteOpen && (
|
||||||
<div className="mt-3 pt-3 border-t border-border space-y-3">
|
<div className="mt-3 pt-3 border-t border-border space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
|
import { Prisma, TaskStatus } from '@prisma/client'
|
||||||
import { TasksClient } from './page-client'
|
import { TasksClient } from './page-client'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
@ -14,9 +15,26 @@ export default async function TasksPage() {
|
||||||
const userRoles = (session.user as any)?.roles || []
|
const userRoles = (session.user as any)?.roles || []
|
||||||
const isPrivileged = userRoles.includes('Admin') || userRoles.includes('Manager')
|
const isPrivileged = userRoles.includes('Admin') || userRoles.includes('Manager')
|
||||||
|
|
||||||
|
const cutoff = new Date()
|
||||||
|
cutoff.setDate(cutoff.getDate() - 7)
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES: TaskStatus[] = [TaskStatus.COMPLETED, TaskStatus.CANCELLED, TaskStatus.NA]
|
||||||
|
|
||||||
|
const activeTaskWhere: Prisma.TaskWhereInput = {
|
||||||
|
assignments: { some: { userId } },
|
||||||
|
NOT: [
|
||||||
|
{ policy: { status: { in: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] } } } as Prisma.TaskWhereInput,
|
||||||
|
{ policyGroup: { renewalDate: { lt: cutoff } } } as Prisma.TaskWhereInput,
|
||||||
|
],
|
||||||
|
OR: [
|
||||||
|
{ dueDate: { gte: cutoff } },
|
||||||
|
{ status: { in: TERMINAL_STATUSES } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
const [myTasksRaw, users] = await Promise.all([
|
const [myTasksRaw, users] = await Promise.all([
|
||||||
prisma.task.findMany({
|
prisma.task.findMany({
|
||||||
where: { assignments: { some: { userId } } },
|
where: activeTaskWhere,
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
|
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
|
||||||
|
|
@ -41,13 +59,13 @@ export default async function TasksPage() {
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const tasks = myTasksRaw.map((t) => ({
|
const tasks = (myTasksRaw as any[]).map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
dueDate: t.dueDate.toISOString(),
|
dueDate: t.dueDate.toISOString(),
|
||||||
policy: t.policy
|
policy: t.policy
|
||||||
? { ...t.policy, expirationDate: t.policy.expirationDate.toISOString() }
|
? { ...t.policy, expirationDate: t.policy.expirationDate.toISOString() }
|
||||||
: null,
|
: null,
|
||||||
taskNotes: t.taskNotes.map((n) => ({ ...n, createdAt: n.createdAt.toISOString() })),
|
taskNotes: t.taskNotes.map((n: any) => ({ ...n, createdAt: n.createdAt.toISOString() })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions, hasPermission } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; contactId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
const userPermissions = (session.user as any).permissions || {}
|
||||||
|
if (!hasPermission(userPermissions, 'clients.write')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
const { contactId } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const { label, name, phone, email, notes } = body
|
||||||
|
const contact = await prisma.clientContact.update({
|
||||||
|
where: { id: contactId },
|
||||||
|
data: {
|
||||||
|
...(label !== undefined && { label: label.trim() }),
|
||||||
|
...(name !== undefined && { name: name.trim() }),
|
||||||
|
...(phone !== undefined && { phone: phone || null }),
|
||||||
|
...(email !== undefined && { email: email || null }),
|
||||||
|
...(notes !== undefined && { notes: notes || null }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; contactId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
const userPermissions = (session.user as any).permissions || {}
|
||||||
|
if (!hasPermission(userPermissions, 'clients.write')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
const { contactId } = await params
|
||||||
|
await prisma.clientContact.delete({ where: { id: contactId } })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
44
ondeck/src/app/api/clients/[id]/contacts/route.ts
Normal file
44
ondeck/src/app/api/clients/[id]/contacts/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions, hasPermission } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
const userPermissions = (session.user as any).permissions || {}
|
||||||
|
if (!hasPermission(userPermissions, 'clients.read')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
const { id } = await params
|
||||||
|
const contacts = await prisma.clientContact.findMany({
|
||||||
|
where: { clientId: id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
return NextResponse.json(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
const userPermissions = (session.user as any).permissions || {}
|
||||||
|
if (!hasPermission(userPermissions, 'clients.write')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const { label, name, phone, email, notes } = body
|
||||||
|
if (!label?.trim() || !name?.trim()) {
|
||||||
|
return NextResponse.json({ error: 'Label and name are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const contact = await prisma.clientContact.create({
|
||||||
|
data: { clientId: id, label: label.trim(), name: name.trim(), phone: phone || null, email: email || null, notes: notes || null },
|
||||||
|
})
|
||||||
|
return NextResponse.json(contact, { status: 201 })
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ export async function PATCH(
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { designationId, designation2Id, claimsAdvocateId, notes, customFields } = body
|
const { designationId, designation2Id, claimsAdvocateId, notes, customFields, parentClientId } = body
|
||||||
|
|
||||||
const existing = await prisma.client.findUnique({
|
const existing = await prisma.client.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -127,6 +127,7 @@ export async function PATCH(
|
||||||
...(designationId !== undefined && { designationId }),
|
...(designationId !== undefined && { designationId }),
|
||||||
...(designation2Id !== undefined && { designation2Id }),
|
...(designation2Id !== undefined && { designation2Id }),
|
||||||
...(claimsAdvocateId !== undefined && { claimsAdvocateId: claimsAdvocateId || null }),
|
...(claimsAdvocateId !== undefined && { claimsAdvocateId: claimsAdvocateId || null }),
|
||||||
|
...(parentClientId !== undefined && { parentClientId: parentClientId || null }),
|
||||||
...(notes !== undefined && { notes }),
|
...(notes !== undefined && { notes }),
|
||||||
...(customFields !== undefined && { customFields }),
|
...(customFields !== undefined && { customFields }),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,34 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const showArchived = searchParams.get('archived') === 'true'
|
||||||
|
|
||||||
|
const cutoff = new Date()
|
||||||
|
cutoff.setDate(cutoff.getDate() - 7)
|
||||||
|
|
||||||
|
const where: any = { clientId: id }
|
||||||
|
|
||||||
|
if (!showArchived) {
|
||||||
|
where.policy = {
|
||||||
|
OR: [
|
||||||
|
{ is: null },
|
||||||
|
{ status: { notIn: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
where.NOT = [
|
||||||
|
{ policyGroup: { renewalDate: { lt: cutoff } } },
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ dueDate: { lt: cutoff } },
|
||||||
|
{ status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const tasks = await prisma.task.findMany({
|
||||||
where: { clientId: id },
|
where,
|
||||||
include: {
|
include: {
|
||||||
assignments: {
|
assignments: {
|
||||||
include: {
|
include: {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export async function POST(
|
||||||
dueDate,
|
dueDate,
|
||||||
status: 'NOT_STARTED' as const,
|
status: 'NOT_STARTED' as const,
|
||||||
priority: template.defaultPriority,
|
priority: template.defaultPriority,
|
||||||
|
taskGroup: template.taskGroup,
|
||||||
clientId: group.clientId,
|
clientId: group.clientId,
|
||||||
policyGroupId: group.id,
|
policyGroupId: group.id,
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
|
|
@ -98,7 +99,7 @@ export async function POST(
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await prisma.task.createMany({
|
const result = await prisma.task.createMany({
|
||||||
data: tasksToCreate,
|
data: tasksToCreate as any[],
|
||||||
})
|
})
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,17 @@ export async function PATCH(
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { status, notes } = body
|
const { status, notes, naReason, imageRightFiled, reminderDate } = body
|
||||||
|
|
||||||
const allowedStatuses = ['COMPLETED', 'NOT_STARTED', 'IN_PROGRESS']
|
const allowedStatuses = ['COMPLETED', 'NOT_STARTED', 'IN_PROGRESS', 'BLOCKED', 'NA', 'CANCELLED']
|
||||||
if (status && !allowedStatuses.includes(status)) {
|
if (status && !allowedStatuses.includes(status)) {
|
||||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 'NA' && !naReason?.trim()) {
|
||||||
|
return NextResponse.json({ error: 'A reason is required when marking a task as N/A' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const updateData: any = {}
|
const updateData: any = {}
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
updateData.status = status
|
updateData.status = status
|
||||||
|
|
@ -56,8 +60,13 @@ export async function PATCH(
|
||||||
updateData.completedAt = null
|
updateData.completedAt = null
|
||||||
updateData.completedBy = null
|
updateData.completedBy = null
|
||||||
}
|
}
|
||||||
|
if (status === 'NA') {
|
||||||
|
updateData.naReason = naReason.trim()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (notes !== undefined) updateData.notes = notes
|
if (notes !== undefined) updateData.notes = notes
|
||||||
|
if (imageRightFiled !== undefined) updateData.imageRightFiled = imageRightFiled
|
||||||
|
if (reminderDate !== undefined) updateData.reminderDate = reminderDate ? new Date(reminderDate) : null
|
||||||
|
|
||||||
const updated = await prisma.task.update({
|
const updated = await prisma.task.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ interface TaskTemplate {
|
||||||
timing: TaskTiming
|
timing: TaskTiming
|
||||||
daysOffset: number
|
daysOffset: number
|
||||||
defaultPriority: TaskPriority
|
defaultPriority: TaskPriority
|
||||||
|
taskGroup: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
displayOrder: number | null
|
displayOrder: number | null
|
||||||
designationId: string | null
|
designationId: string | null
|
||||||
|
|
@ -99,6 +100,7 @@ const emptyTemplate = {
|
||||||
timing: 'PRE_RENEWAL' as TaskTiming,
|
timing: 'PRE_RENEWAL' as TaskTiming,
|
||||||
daysOffset: -90,
|
daysOffset: -90,
|
||||||
defaultPriority: 'MEDIUM' as TaskPriority,
|
defaultPriority: 'MEDIUM' as TaskPriority,
|
||||||
|
taskGroup: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: null as number | null,
|
displayOrder: null as number | null,
|
||||||
designationId: null as string | null,
|
designationId: null as string | null,
|
||||||
|
|
@ -149,6 +151,7 @@ export function TaskTemplateManager({
|
||||||
timing: template.timing,
|
timing: template.timing,
|
||||||
daysOffset: template.daysOffset,
|
daysOffset: template.daysOffset,
|
||||||
defaultPriority: template.defaultPriority,
|
defaultPriority: template.defaultPriority,
|
||||||
|
taskGroup: template.taskGroup || '',
|
||||||
isActive: template.isActive,
|
isActive: template.isActive,
|
||||||
displayOrder: template.displayOrder,
|
displayOrder: template.displayOrder,
|
||||||
designationId: template.designationId,
|
designationId: template.designationId,
|
||||||
|
|
@ -347,6 +350,17 @@ export function TaskTemplateManager({
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="taskGroup">Task Group</Label>
|
||||||
|
<Input
|
||||||
|
id="taskGroup"
|
||||||
|
value={formData.taskGroup}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, taskGroup: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Renewal, Claims, Audit (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Department</Label>
|
<Label>Department</Label>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck } from 'lucide-react'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
import { Combobox } from '@/components/ui/combobox'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||||
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
|
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
|
||||||
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
|
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -30,14 +31,28 @@ interface ClientDetailProps {
|
||||||
client: any
|
client: any
|
||||||
designations: any[]
|
designations: any[]
|
||||||
policyGroups?: any[]
|
policyGroups?: any[]
|
||||||
|
allPolicies?: any[]
|
||||||
canManageGroups?: boolean
|
canManageGroups?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientDetail({ client, designations, policyGroups = [], canManageGroups = false }: ClientDetailProps) {
|
export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false }: ClientDetailProps) {
|
||||||
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
|
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
|
||||||
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
|
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [notes, setNotes] = useState<string>(client.notes || '')
|
||||||
|
const [notesSaving, setNotesSaving] = useState(false)
|
||||||
const [tasks, setTasks] = useState<any[]>(client.tasks || [])
|
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 [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
|
const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
|
||||||
const [sessionUserId, setSessionUserId] = useState('')
|
const [sessionUserId, setSessionUserId] = useState('')
|
||||||
|
|
||||||
|
|
@ -48,9 +63,10 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshTasks = async () => {
|
const refreshTasks = async (archived = showArchivedTasks) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/clients/${client.id}/tasks`)
|
const url = `/api/clients/${client.id}/tasks${archived ? '?archived=true' : ''}`
|
||||||
|
const res = await fetch(url)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
|
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
|
||||||
|
|
@ -58,6 +74,12 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleArchived = () => {
|
||||||
|
const next = !showArchivedTasks
|
||||||
|
setShowArchivedTasks(next)
|
||||||
|
refreshTasks(next)
|
||||||
|
}
|
||||||
|
|
||||||
// Assignments state
|
// Assignments state
|
||||||
const [allUsers, setAllUsers] = useState<SimpleUser[]>([])
|
const [allUsers, setAllUsers] = useState<SimpleUser[]>([])
|
||||||
const [claimsUsers, setClaimsUsers] = useState<SimpleUser[]>([])
|
const [claimsUsers, setClaimsUsers] = useState<SimpleUser[]>([])
|
||||||
|
|
@ -128,6 +150,124 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
toast.success('Notes saved')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save notes')
|
||||||
|
} finally {
|
||||||
|
setNotesSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDesignationUpdate = async () => {
|
const handleDesignationUpdate = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -247,6 +387,104 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Related Companies */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
Related Companies
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Parent Company */}
|
||||||
|
<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">
|
||||||
|
<div className="relative">
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{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 */}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Client Notes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<StickyNote className="h-5 w-5" />
|
||||||
|
Client Notes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add notes about this client (e.g. reporting instructions, special handling)..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleNotesSave} disabled={notesSaving} size="sm">
|
||||||
|
{notesSaving ? 'Saving...' : 'Save Notes'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Designation Assignment */}
|
{/* Designation Assignment */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -296,7 +534,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="policies">
|
<TabsTrigger value="policies">
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
Policies ({client.policies.length})
|
Active Policies ({client.policies.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tasks">
|
<TabsTrigger value="tasks">
|
||||||
<CheckSquare className="h-4 w-4 mr-2" />
|
<CheckSquare className="h-4 w-4 mr-2" />
|
||||||
|
|
@ -306,6 +544,10 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
<CalendarRange className="h-4 w-4 mr-2" />
|
<CalendarRange className="h-4 w-4 mr-2" />
|
||||||
Renewal Groups ({policyGroups.length})
|
Renewal Groups ({policyGroups.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="contacts">
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
Contacts ({contacts.length})
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="policies" className="space-y-4">
|
<TabsContent value="policies" className="space-y-4">
|
||||||
|
|
@ -331,7 +573,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={new Date(policy.expirationDate) < new Date() ? 'destructive' : 'default'}>
|
<Badge variant={new Date(policy.expirationDate) < new Date() ? 'destructive' : 'default'}>
|
||||||
<span suppressHydrationWarning>
|
<span suppressHydrationWarning>
|
||||||
Renewal {formatDate(policy.expirationDate)}
|
Renewal {formatRenewalDate(policy.expirationDate)}
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -405,7 +647,14 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="tasks" className="space-y-4">
|
<TabsContent value="tasks" className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showArchivedTasks ? 'default' : 'outline'}
|
||||||
|
onClick={handleToggleArchived}
|
||||||
|
>
|
||||||
|
{showArchivedTasks ? 'Hide archived' : 'Show archived'}
|
||||||
|
</Button>
|
||||||
<Button size="sm" className="gap-1.5" onClick={() => setAdditionalServiceOpen(true)}>
|
<Button size="sm" className="gap-1.5" onClick={() => setAdditionalServiceOpen(true)}>
|
||||||
<Plus className="h-4 w-4" /> Additional Service
|
<Plus className="h-4 w-4" /> Additional Service
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -456,18 +705,149 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
||||||
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
|
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
onCreated={refreshTasks}
|
onCreated={() => refreshTasks()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabsContent value="renewal-groups">
|
<TabsContent value="renewal-groups">
|
||||||
<PolicyGroupManager
|
<PolicyGroupManager
|
||||||
clientId={client.id}
|
clientId={client.id}
|
||||||
initialGroups={policyGroups}
|
initialGroups={policyGroups}
|
||||||
allPolicies={client.policies}
|
allPolicies={allPolicies ?? client.policies}
|
||||||
canManage={canManageGroups}
|
canManage={canManageGroups}
|
||||||
onTasksGenerated={refreshTasks}
|
onTasksGenerated={() => refreshTasks()}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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>
|
||||||
|
<span className="font-medium text-sm truncate">{contact.name}</span>
|
||||||
|
</div>
|
||||||
|
{contact.phone && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<Phone className="h-3 w-3" />{contact.phone}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||||
|
|
||||||
interface Policy {
|
interface Policy {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -409,7 +409,7 @@ export function PolicyGroupManager({
|
||||||
{policy.writingCompanyName && (
|
{policy.writingCompanyName && (
|
||||||
<span>{policy.writingCompanyName}</span>
|
<span>{policy.writingCompanyName}</span>
|
||||||
)}
|
)}
|
||||||
<span suppressHydrationWarning>Exp: {formatDate(policy.expirationDate)}</span>
|
<span suppressHydrationWarning>Renewal: {formatRenewalDate(policy.expirationDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -506,7 +506,7 @@ export function PolicyGroupManager({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<span suppressHydrationWarning>Exp: {formatDate(policy.expirationDate)}</span>
|
<span suppressHydrationWarning>Renewal: {formatRenewalDate(policy.expirationDate)}</span>
|
||||||
{policy.writingCompanyName && ` · ${policy.writingCompanyName}`}
|
{policy.writingCompanyName && ` · ${policy.writingCompanyName}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,21 @@ export function formatDate(date: Date | string | null | undefined, options?: Int
|
||||||
}).format(dateObj)
|
}).format(dateObj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a policy expiration date as the renewal date (expiration + 1 day).
|
||||||
|
* AMS stores expiration date (e.g. 12/31/2026); the renewal date is 1/1/2027.
|
||||||
|
*/
|
||||||
|
export function formatRenewalDate(expirationDate: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
if (!expirationDate) return ''
|
||||||
|
const d = typeof expirationDate === 'string' ? new Date(expirationDate) : new Date(expirationDate)
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
|
return new Intl.DateTimeFormat('en-US', options || {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a date to relative time (e.g., "2 days ago", "in 3 hours")
|
* Format a date to relative time (e.g., "2 days ago", "in 3 hours")
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue