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")
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 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 }),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue