From 0d8edc97dae2bec44e4884453793a0531b1447b9 Mon Sep 17 00:00:00 2001
From: lorentz
Date: Tue, 31 Mar 2026 16:40:49 +0000
Subject: [PATCH] Additional Service: ad hoc task creation on tasks page,
client detail, policy group manager
---
ondeck/prisma/schema.prisma | 1 +
.../src/app/(dashboard)/tasks/page-client.tsx | 23 +-
.../src/app/api/tasks/adhoc-recent/route.ts | 26 ++
ondeck/src/app/api/tasks/route.ts | 4 +-
ondeck/src/components/clients/client-card.tsx | 4 +-
.../src/components/clients/client-detail.tsx | 38 +-
.../src/components/clients/client-table.tsx | 2 +-
.../clients/policy-group-manager.tsx | 55 ++-
.../tasks/additional-service-modal.tsx | 394 ++++++++++++++++++
9 files changed, 539 insertions(+), 8 deletions(-)
create mode 100644 ondeck/src/app/api/tasks/adhoc-recent/route.ts
create mode 100644 ondeck/src/components/tasks/additional-service-modal.tsx
diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma
index 89b8688..c48dc9d 100644
--- a/ondeck/prisma/schema.prisma
+++ b/ondeck/prisma/schema.prisma
@@ -291,6 +291,7 @@ model Task {
completedAt DateTime? @map("completed_at")
completedBy String? @map("completed_by")
notes String? @db.Text
+ isAdHoc Boolean @default(false) @map("is_ad_hoc")
naReason String? @map("na_reason") @db.Text
cancelledReason String? @map("cancelled_reason") @db.Text
createdAt DateTime @default(now()) @map("created_at")
diff --git a/ondeck/src/app/(dashboard)/tasks/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/page-client.tsx
index 293b5d2..6998b93 100644
--- a/ondeck/src/app/(dashboard)/tasks/page-client.tsx
+++ b/ondeck/src/app/(dashboard)/tasks/page-client.tsx
@@ -22,9 +22,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
-import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2 } from 'lucide-react'
+import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { formatDate } from '@/lib/utils'
+import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
interface TaskUser { displayName: string | null; email: string }
interface TaskAssignment { id: string; user: TaskUser }
@@ -290,6 +291,7 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
const [transferOpen, setTransferOpen] = useState(false)
const [transferTo, setTransferTo] = useState('')
const [transferring, setTransferring] = useState(false)
+ const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
const [viewingUserId, setViewingUserId] = useState(currentUserId)
const [loadingTasks, setLoadingTasks] = useState(false)
const [clientFilter, setClientFilter] = useState('')
@@ -420,8 +422,17 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
+
+
+
{isPrivileged && (
-
+
Viewing as:
)}
+
{/* Client filter */}
@@ -729,6 +741,13 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
+
+ fetchTasks(viewingUserId, clientFilter || undefined)}
+ />
)
}
diff --git a/ondeck/src/app/api/tasks/adhoc-recent/route.ts b/ondeck/src/app/api/tasks/adhoc-recent/route.ts
new file mode 100644
index 0000000..50bb840
--- /dev/null
+++ b/ondeck/src/app/api/tasks/adhoc-recent/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/db'
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const userId = (session.user as any).id
+
+ const recent = await prisma.task.findMany({
+ where: { isAdHoc: true, createdBy: userId },
+ select: { id: true, title: true, description: true, priority: true, department: true },
+ orderBy: { createdAt: 'desc' },
+ take: 10,
+ distinct: ['title'],
+ })
+
+ return NextResponse.json(recent)
+ } catch (error) {
+ console.error('adhoc-recent error:', error)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/ondeck/src/app/api/tasks/route.ts b/ondeck/src/app/api/tasks/route.ts
index 7691a18..20d58a2 100644
--- a/ondeck/src/app/api/tasks/route.ts
+++ b/ondeck/src/app/api/tasks/route.ts
@@ -114,14 +114,16 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
- const { clientId, policyId, templateId, assignedUserIds, ...taskData } = body
+ const { clientId, policyId, policyGroupId, templateId, isAdHoc, assignedUserIds, ...taskData } = body
const task = await prisma.task.create({
data: {
...taskData,
clientId,
policyId,
+ policyGroupId,
templateId,
+ isAdHoc: isAdHoc ?? false,
createdBy: (session.user as any).id,
assignments: assignedUserIds
? {
diff --git a/ondeck/src/components/clients/client-card.tsx b/ondeck/src/components/clients/client-card.tsx
index bb2f1e0..2e364f6 100644
--- a/ondeck/src/components/clients/client-card.tsx
+++ b/ondeck/src/components/clients/client-card.tsx
@@ -80,12 +80,12 @@ export function ClientCard({ client }: ClientCardProps) {
)}
- {/* Next Policy Expiration */}
+ {/* Renewal Date */}
{nextPolicy && (
-
Next expiration:
+
Renewal date:
{formatDate(nextPolicy.expirationDate)}
diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx
index e807608..9ae1947 100644
--- a/ondeck/src/components/clients/client-detail.tsx
+++ b/ondeck/src/components/clients/client-detail.tsx
@@ -17,6 +17,7 @@ import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, U
import { Combobox } from '@/components/ui/combobox'
import { formatDate } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
+import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { toast } from 'sonner'
interface SimpleUser {
@@ -37,6 +38,15 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
const [tasks, setTasks] = useState
(client.tasks || [])
+ const [additionalServiceOpen, setAdditionalServiceOpen] = useState(false)
+ const [sessionUserId, setSessionUserId] = useState('')
+
+ useEffect(() => {
+ fetch('/api/auth/session')
+ .then((r) => r.json())
+ .then((s) => setSessionUserId(s?.user?.id ?? ''))
+ .catch(() => {})
+ }, [])
const refreshTasks = async () => {
try {
@@ -321,7 +331,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
- Expires {formatDate(policy.expirationDate)}
+ Renewal {formatDate(policy.expirationDate)}
@@ -395,6 +405,11 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
+
+
+
{tasks.length === 0 ? (
@@ -423,6 +438,27 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
)}
+ ({
+ id: p.id,
+ policyNumber: p.policyNumber,
+ policyType: p.policyType,
+ })),
+ policyGroups: policyGroups.map((g: any) => ({
+ id: g.id,
+ name: g.name,
+ renewalDate: g.renewalDate ? new Date(g.renewalDate).toLocaleDateString() : undefined,
+ })),
+ }}
+ onCreated={refreshTasks}
+ />
+
Client
Location
Designation
- Next Expiration
+ Renewal Date
Policy Type
Carrier
Executive
diff --git a/ondeck/src/components/clients/policy-group-manager.tsx b/ondeck/src/components/clients/policy-group-manager.tsx
index 19d1249..a86c80d 100644
--- a/ondeck/src/components/clients/policy-group-manager.tsx
+++ b/ondeck/src/components/clients/policy-group-manager.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import {
Plus,
@@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
+import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -94,6 +95,15 @@ export function PolicyGroupManager({
const [deleteTarget, setDeleteTarget] = useState(null)
const [cancelTasks, setCancelTasks] = useState(false)
const [generatingFor, setGeneratingFor] = useState(null)
+ const [adhocGroupId, setAdhocGroupId] = useState(null)
+ const [sessionUserId, setSessionUserId] = useState('')
+
+ useEffect(() => {
+ fetch('/api/auth/session')
+ .then((r) => r.json())
+ .then((s) => setSessionUserId(s?.user?.id ?? ''))
+ .catch(() => {})
+ }, [])
const assignedPolicyIds = new Set(
groups.flatMap((g) => g.policies.map((p) => p.id))
@@ -322,6 +332,14 @@ export function PolicyGroupManager({
{canManage && (
<>
+
)
}
diff --git a/ondeck/src/components/tasks/additional-service-modal.tsx b/ondeck/src/components/tasks/additional-service-modal.tsx
new file mode 100644
index 0000000..10d9e34
--- /dev/null
+++ b/ondeck/src/components/tasks/additional-service-modal.tsx
@@ -0,0 +1,394 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Plus, Wand2 } from 'lucide-react'
+
+interface SimpleUser {
+ id: string
+ displayName: string | null
+ email: string
+ department?: string | null
+}
+
+interface PolicyOption {
+ id: string
+ policyNumber: string | null
+ policyType: string | null
+}
+
+interface PolicyGroupOption {
+ id: string
+ name?: string | null
+ renewalDate?: string | null
+}
+
+interface RecentAdHoc {
+ id: string
+ title: string
+ description: string | null
+ priority: string
+ department: string
+}
+
+interface AdditionalServiceModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ currentUserId: string
+ /** Pre-fill context — pass what's known from the current surface */
+ context?: {
+ clientId?: string
+ clientName?: string
+ policyId?: string
+ policyGroupId?: string
+ policies?: PolicyOption[]
+ policyGroups?: PolicyGroupOption[]
+ }
+ onCreated?: () => void
+}
+
+const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']
+const DEPARTMENTS = ['Claims', 'Personal Lines', 'Commercial Lines', 'Benefits', 'Life', 'Other']
+const TIMINGS = ['BEFORE_RENEWAL', 'AT_RENEWAL', 'AFTER_RENEWAL', 'ONGOING']
+
+export function AdditionalServiceModal({
+ open,
+ onOpenChange,
+ currentUserId,
+ context,
+ onCreated,
+}: AdditionalServiceModalProps) {
+ const [level, setLevel] = useState<'client' | 'policy' | 'group'>('client')
+ const [title, setTitle] = useState('')
+ const [description, setDescription] = useState('')
+ const [priority, setPriority] = useState('MEDIUM')
+ const [department, setDepartment] = useState(DEPARTMENTS[0])
+ const [dueDate, setDueDate] = useState('')
+ const [assignTo, setAssignTo] = useState(currentUserId)
+ const [saving, setSaving] = useState(false)
+
+ const [users, setUsers] = useState([])
+ const [recentAdHoc, setRecentAdHoc] = useState([])
+
+ // Client search (when no clientId pre-filled)
+ const [clientSearch, setClientSearch] = useState(context?.clientName ?? '')
+ const [clientId, setClientId] = useState(context?.clientId ?? '')
+ const [clientOptions, setClientOptions] = useState<{ id: string; name: string }[]>([])
+ const [clientDropOpen, setClientDropOpen] = useState(false)
+
+ const [selectedPolicyId, setSelectedPolicyId] = useState(context?.policyId ?? '')
+ const [selectedGroupId, setSelectedGroupId] = useState(context?.policyGroupId ?? '')
+
+ // Load users and recent ad hoc tasks when modal opens
+ useEffect(() => {
+ if (!open) return
+ // Reset form
+ setTitle('')
+ setDescription('')
+ setPriority('MEDIUM')
+ setDepartment(DEPARTMENTS[0])
+ setDueDate('')
+ setAssignTo(currentUserId)
+ setLevel(context?.policyId ? 'policy' : context?.policyGroupId ? 'group' : 'client')
+ setClientId(context?.clientId ?? '')
+ setClientSearch(context?.clientName ?? '')
+ setSelectedPolicyId(context?.policyId ?? '')
+ setSelectedGroupId(context?.policyGroupId ?? '')
+
+ fetch('/api/users?isActive=true&limit=200')
+ .then((r) => r.json())
+ .then((d) => setUsers(d.users || []))
+ .catch(() => {})
+
+ fetch('/api/tasks/adhoc-recent')
+ .then((r) => r.json())
+ .then((d) => setRecentAdHoc(Array.isArray(d) ? d : []))
+ .catch(() => {})
+ }, [open])
+
+ // Client search debounce
+ useEffect(() => {
+ if (context?.clientId || !clientSearch.trim()) { setClientOptions([]); return }
+ const t = setTimeout(async () => {
+ try {
+ const res = await fetch(`/api/clients?search=${encodeURIComponent(clientSearch)}&limit=20`)
+ const data = await res.json()
+ setClientOptions((data.clients || []).map((c: any) => ({ id: c.id, name: c.name })))
+ setClientDropOpen(true)
+ } catch {}
+ }, 250)
+ return () => clearTimeout(t)
+ }, [clientSearch, context?.clientId])
+
+ const applyRecent = (r: RecentAdHoc) => {
+ setTitle(r.title)
+ setDescription(r.description ?? '')
+ setPriority(r.priority)
+ setDepartment(r.department)
+ }
+
+ const handleSubmit = async () => {
+ if (!title.trim()) { toast.error('Title is required'); return }
+ if (!dueDate) { toast.error('Due date is required'); return }
+ if (!clientId) { toast.error('Please select a client'); return }
+
+ setSaving(true)
+ try {
+ const body: any = {
+ title: title.trim(),
+ description: description.trim() || null,
+ priority,
+ department,
+ dueDate: new Date(dueDate).toISOString(),
+ timing: 'ONGOING',
+ daysOffset: 0,
+ status: 'NOT_STARTED',
+ isAdHoc: true,
+ clientId,
+ assignedUserIds: [assignTo],
+ }
+
+ if (level === 'policy' && selectedPolicyId) body.policyId = selectedPolicyId
+ if (level === 'group' && selectedGroupId) body.policyGroupId = selectedGroupId
+
+ const res = await fetch('/api/tasks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const data = await res.json()
+ if (!res.ok) throw new Error(data.error)
+
+ toast.success('Additional service task created')
+ onOpenChange(false)
+ onCreated?.()
+ } catch (err: any) {
+ toast.error(err.message || 'Failed to create task')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const claimsUsers = users.filter((u) => u.department?.toLowerCase().includes('claims'))
+ const otherUsers = users.filter((u) => !u.department?.toLowerCase().includes('claims'))
+
+ return (
+
+ )
+}