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:
lorentz 2026-04-07 01:56:18 +00:00
parent 0ac872a4da
commit 9dd30de358
14 changed files with 791 additions and 29 deletions

View file

@ -122,6 +122,7 @@ model Client {
designationId String? @map("designation_id")
designation2Id String? @map("designation2_id")
claimsAdvocateId String? @map("claims_advocate_id")
parentClientId String? @map("parent_client_id")
notes String? @db.Text
customFields Json @default("{}") @map("custom_fields")
lastSyncedAt DateTime? @map("last_synced_at")
@ -131,18 +132,39 @@ model Client {
designation Designation? @relation("ClientDesignation1", fields: [designationId], references: [id])
designation2 Designation? @relation("ClientDesignation2", fields: [designation2Id], references: [id])
claimsAdvocate User? @relation("ClientAdvocate", fields: [claimsAdvocateId], references: [id])
parentClient Client? @relation("ClientParent", fields: [parentClientId], references: [id])
subsidiaries Client[] @relation("ClientParent")
policies Policy[]
tasks Task[]
policyGroups PolicyGroup[]
members ClientMember[]
contacts ClientContact[]
@@index([name])
@@index([designationId])
@@index([designation2Id])
@@index([claimsAdvocateId])
@@index([parentClientId])
@@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 {
id String @id @default(cuid())
clientId String @map("client_id")
@ -258,6 +280,7 @@ model TaskTemplate {
timing TaskTiming
daysOffset Int @map("days_offset")
defaultPriority TaskPriority @map("default_priority")
taskGroup String? @map("task_group")
isActive Boolean @default(true) @map("is_active")
displayOrder Int? @map("display_order")
designationId String? @map("designation_id")
@ -294,6 +317,9 @@ model Task {
isAdHoc Boolean @default(false) @map("is_ad_hoc")
naReason String? @map("na_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")
updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -23,7 +23,7 @@ export default async function ClientDetailPage({
const userRoles = (session.user as any).roles || []
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({
where: { id },
include: {
@ -32,6 +32,17 @@ export default async function ClientDetailPage({
claimsAdvocate: {
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: {
include: {
user: { select: { id: true, displayName: true, email: true } },
@ -39,9 +50,24 @@ export default async function ClientDetailPage({
orderBy: { createdAt: 'asc' as const },
},
policies: {
where: { status: 'Active' },
orderBy: { expirationDate: 'desc' },
},
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: {
assignments: {
include: {
@ -83,6 +109,21 @@ export default async function ClientDetailPage({
},
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) {
@ -98,12 +139,18 @@ export default async function ClientDetailPage({
})),
}
const allPoliciesData = allPolicies.map(policy => ({
...policy,
premiumAmount: policy.premiumAmount ? Number(policy.premiumAmount) : null,
}))
return (
<div className="container mx-auto py-8">
<ClientDetail
client={clientData}
designations={designations}
policyGroups={policyGroups}
allPolicies={allPoliciesData}
canManageGroups={canManageGroups}
/>
</div>

View file

@ -22,7 +22,7 @@ import {
SelectTrigger,
SelectValue,
} 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 { formatDate } from '@/lib/utils'
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 [saving, setSaving] = useState(false)
const [toggling, setToggling] = useState(false)
const [naDialogOpen, setNaDialogOpen] = useState(false)
const [naReason, setNaReason] = useState('')
const [naSubmitting, setNaSubmitting] = useState(false)
const [completeDialogOpen, setCompleteDialogOpen] = useState(false)
const [imageRightFiled, setImageRightFiled] = useState<boolean | null>(null)
const [reminderDate, setReminderDate] = useState('')
const [completeSubmitting, setCompleteSubmitting] = useState(false)
const now = new Date()
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
const isCompleted = task.status === 'COMPLETED'
const handleToggleStatus = async () => {
if (!isCompleted) {
setImageRightFiled(null)
setReminderDate('')
setCompleteDialogOpen(true)
return
}
setToggling(true)
try {
const newStatus = isCompleted ? 'NOT_STARTED' : 'COMPLETED'
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
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)
setTask((t) => ({ ...t, status: newStatus }))
setNoteStatus(newStatus)
toast.success(newStatus === 'COMPLETED' ? 'Task marked complete' : 'Task reopened')
setTask((t) => ({ ...t, status: 'NOT_STARTED' }))
setNoteStatus('NOT_STARTED')
toast.success('Task reopened')
} catch (err: any) {
toast.error(err.message)
} 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 () => {
if (!newNote.trim()) return
setSaving(true)
@ -231,6 +288,17 @@ function TaskCard({ task: initial }: { task: Task }) {
: <><CheckCircle2 className="h-3.5 w-3.5" /> Complete</>
}
</Button>
{task.status !== 'COMPLETED' && task.status !== 'NA' && task.status !== 'CANCELLED' && (
<Button
size="sm"
variant="ghost"
onClick={() => setNaDialogOpen(true)}
className="gap-1.5 text-muted-foreground hover:text-foreground"
title="Mark as N/A"
>
<Ban className="h-3.5 w-3.5" /> N/A
</Button>
)}
<Button
size="sm"
variant={noteOpen ? 'secondary' : 'ghost'}
@ -243,6 +311,75 @@ function TaskCard({ task: initial }: { task: Task }) {
</div>
</div>
{/* Completion Dialog */}
<Dialog open={completeDialogOpen} onOpenChange={setCompleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Complete Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<p className="text-sm font-medium mb-2">Did you file in ImageRight?</p>
<div className="flex gap-2">
<Button
size="sm"
variant={imageRightFiled === true ? 'default' : 'outline'}
onClick={() => setImageRightFiled(true)}
>
Yes
</Button>
<Button
size="sm"
variant={imageRightFiled === false ? 'default' : 'outline'}
onClick={() => setImageRightFiled(false)}
>
No
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Set a reminder? <span className="text-muted-foreground font-normal">(optional)</span></p>
<Input
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-48"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCompleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleConfirmComplete} disabled={imageRightFiled === null || completeSubmitting}>
{completeSubmitting ? 'Saving...' : 'Mark Complete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* N/A Dialog */}
<Dialog open={naDialogOpen} onOpenChange={setNaDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark as N/A</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">Please provide a reason why this task is not applicable.</p>
<Textarea
value={naReason}
onChange={(e) => setNaReason(e.target.value)}
placeholder="Enter reason (required)..."
rows={3}
className="mt-2"
/>
<DialogFooter>
<Button variant="outline" onClick={() => { setNaDialogOpen(false); setNaReason('') }}>Cancel</Button>
<Button onClick={handleMarkNA} disabled={!naReason.trim() || naSubmitting}>
{naSubmitting ? 'Saving...' : 'Confirm N/A'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Notes panel */}
{noteOpen && (
<div className="mt-3 pt-3 border-t border-border space-y-3">

View file

@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Prisma, TaskStatus } from '@prisma/client'
import { TasksClient } from './page-client'
export const dynamic = 'force-dynamic'
@ -14,9 +15,26 @@ export default async function TasksPage() {
const userRoles = (session.user as any)?.roles || []
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([
prisma.task.findMany({
where: { assignments: { some: { userId } } },
where: activeTaskWhere,
include: {
client: { select: { id: true, name: true } },
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true } },
@ -41,13 +59,13 @@ export default async function TasksPage() {
: Promise.resolve([]),
])
const tasks = myTasksRaw.map((t) => ({
const tasks = (myTasksRaw as any[]).map((t) => ({
...t,
dueDate: t.dueDate.toISOString(),
policy: t.policy
? { ...t.policy, expirationDate: t.policy.expirationDate.toISOString() }
: null,
taskNotes: t.taskNotes.map((n) => ({ ...n, createdAt: n.createdAt.toISOString() })),
taskNotes: t.taskNotes.map((n: any) => ({ ...n, createdAt: n.createdAt.toISOString() })),
}))
return (

View file

@ -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 })
}

View 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 })
}

View file

@ -114,7 +114,7 @@ export async function PATCH(
const { id } = await params
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({
where: { id },
@ -127,6 +127,7 @@ export async function PATCH(
...(designationId !== undefined && { designationId }),
...(designation2Id !== undefined && { designation2Id }),
...(claimsAdvocateId !== undefined && { claimsAdvocateId: claimsAdvocateId || null }),
...(parentClientId !== undefined && { parentClientId: parentClientId || null }),
...(notes !== undefined && { notes }),
...(customFields !== undefined && { customFields }),
},

View file

@ -17,9 +17,34 @@ export async function GET(
}
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({
where: { clientId: id },
where,
include: {
assignments: {
include: {

View file

@ -90,6 +90,7 @@ export async function POST(
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
taskGroup: template.taskGroup,
clientId: group.clientId,
policyGroupId: group.id,
templateId: template.id,
@ -98,7 +99,7 @@ export async function POST(
})
const result = await prisma.task.createMany({
data: tasksToCreate,
data: tasksToCreate as any[],
})
await prisma.auditLog.create({

View file

@ -39,13 +39,17 @@ export async function PATCH(
}
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)) {
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 = {}
if (status !== undefined) {
updateData.status = status
@ -56,8 +60,13 @@ export async function PATCH(
updateData.completedAt = null
updateData.completedBy = null
}
if (status === 'NA') {
updateData.naReason = naReason.trim()
}
}
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({
where: { id },

View file

@ -60,6 +60,7 @@ interface TaskTemplate {
timing: TaskTiming
daysOffset: number
defaultPriority: TaskPriority
taskGroup: string | null
isActive: boolean
displayOrder: number | null
designationId: string | null
@ -99,6 +100,7 @@ const emptyTemplate = {
timing: 'PRE_RENEWAL' as TaskTiming,
daysOffset: -90,
defaultPriority: 'MEDIUM' as TaskPriority,
taskGroup: '',
isActive: true,
displayOrder: null as number | null,
designationId: null as string | null,
@ -149,6 +151,7 @@ export function TaskTemplateManager({
timing: template.timing,
daysOffset: template.daysOffset,
defaultPriority: template.defaultPriority,
taskGroup: template.taskGroup || '',
isActive: template.isActive,
displayOrder: template.displayOrder,
designationId: template.designationId,
@ -347,6 +350,17 @@ export function TaskTemplateManager({
placeholder="Optional description"
/>
</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 gap-2">
<Label>Department</Label>

View file

@ -13,9 +13,10 @@ import {
SelectTrigger,
SelectValue,
} 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 { formatDate } from '@/lib/utils'
import { formatDate, formatRenewalDate } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { toast } from 'sonner'
@ -30,14 +31,28 @@ interface ClientDetailProps {
client: any
designations: any[]
policyGroups?: any[]
allPolicies?: any[]
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 [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
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 [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 [sessionUserId, setSessionUserId] = useState('')
@ -48,9 +63,10 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
.catch(() => {})
}, [])
const refreshTasks = async () => {
const refreshTasks = async (archived = showArchivedTasks) => {
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) {
const data = await res.json()
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
@ -58,6 +74,12 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
} catch {}
}
const handleToggleArchived = () => {
const next = !showArchivedTasks
setShowArchivedTasks(next)
refreshTasks(next)
}
// Assignments state
const [allUsers, setAllUsers] = 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 () => {
setSaving(true)
try {
@ -247,6 +387,104 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</CardContent>
</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 */}
<Card>
<CardHeader>
@ -296,7 +534,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
<TabsList>
<TabsTrigger value="policies">
<FileText className="h-4 w-4 mr-2" />
Policies ({client.policies.length})
Active Policies ({client.policies.length})
</TabsTrigger>
<TabsTrigger value="tasks">
<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" />
Renewal Groups ({policyGroups.length})
</TabsTrigger>
<TabsTrigger value="contacts">
<Users className="h-4 w-4 mr-2" />
Contacts ({contacts.length})
</TabsTrigger>
</TabsList>
<TabsContent value="policies" className="space-y-4">
@ -331,7 +573,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</div>
<Badge variant={new Date(policy.expirationDate) < new Date() ? 'destructive' : 'default'}>
<span suppressHydrationWarning>
Renewal {formatDate(policy.expirationDate)}
Renewal {formatRenewalDate(policy.expirationDate)}
</span>
</Badge>
</div>
@ -405,7 +647,14 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
</TabsContent>
<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)}>
<Plus className="h-4 w-4" /> Additional Service
</Button>
@ -456,18 +705,149 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
})),
}}
onCreated={refreshTasks}
onCreated={() => refreshTasks()}
/>
<TabsContent value="renewal-groups">
<PolicyGroupManager
clientId={client.id}
initialGroups={policyGroups}
allPolicies={client.policies}
allPolicies={allPolicies ?? client.policies}
canManage={canManageGroups}
onTasksGenerated={refreshTasks}
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>
<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>
</div>
)

View file

@ -40,7 +40,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { formatDate } from '@/lib/utils'
import { formatDate, formatRenewalDate } from '@/lib/utils'
interface Policy {
id: string
@ -409,7 +409,7 @@ export function PolicyGroupManager({
{policy.writingCompanyName && (
<span>{policy.writingCompanyName}</span>
)}
<span suppressHydrationWarning>Exp: {formatDate(policy.expirationDate)}</span>
<span suppressHydrationWarning>Renewal: {formatRenewalDate(policy.expirationDate)}</span>
</div>
</div>
))}
@ -506,7 +506,7 @@ export function PolicyGroupManager({
)}
</div>
<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}`}
</div>
</div>

View file

@ -18,6 +18,21 @@ export function formatDate(date: Date | string | null | undefined, options?: Int
}).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")
*/