Feature: policy groups, task assignment, claims advocate, client members, audit log, combobox UI

- Schema: add PolicyGroup, ClientMember, claimsAdvocateId; migrations included
- API: /api/clients/[id]/team (flat ClientMember), /api/clients/[id]/policy-groups
- API: /api/policy-groups, /api/tasks/bulk-assign, /api/cron/auto-generate
- API: /api/admin/audit, filter clients by advocateId/teamMemberId (name format fix)
- API: /api/users supports department filter
- UI: policy-group-manager, client-detail assignments card (combobox, claims dept filter)
- UI: bulk assign page /tasks/assign, audit log viewer /admin/audit
- UI: nav-bar Assign Tasks link, admin audit log link
- UI: combobox component with search/filter
- Auth: audit logging for sign-in, user role changes
- Fix: Prisma client regenerated for new schema fields
- Fix: teamMemberId filter uses Last,First name format for policy matching
- Fix: suppressHydrationWarning on all formatDate spans
This commit is contained in:
lorentz 2026-03-18 01:49:52 +00:00
parent 6f2b8efef0
commit 0abcadaa71
31 changed files with 3053 additions and 102 deletions

View file

@ -0,0 +1,43 @@
-- AlterTable
ALTER TABLE "policies" ADD COLUMN "policy_group_id" TEXT;
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "policy_group_id" TEXT;
-- CreateTable
CREATE TABLE "policy_groups" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"renewal_date" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"created_by" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "policy_groups_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "policy_groups_client_id_idx" ON "policy_groups"("client_id");
-- CreateIndex
CREATE INDEX "policy_groups_renewal_date_idx" ON "policy_groups"("renewal_date");
-- CreateIndex
CREATE INDEX "policies_policy_group_id_idx" ON "policies"("policy_group_id");
-- CreateIndex
CREATE INDEX "tasks_policy_group_id_idx" ON "tasks"("policy_group_id");
-- AddForeignKey
ALTER TABLE "policy_groups" ADD CONSTRAINT "policy_groups_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "policy_groups" ADD CONSTRAINT "policy_groups_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "policies" ADD CONSTRAINT "policies_policy_group_id_fkey" FOREIGN KEY ("policy_group_id") REFERENCES "policy_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_policy_group_id_fkey" FOREIGN KEY ("policy_group_id") REFERENCES "policy_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,44 @@
-- AlterTable
ALTER TABLE "clients" ADD COLUMN "claims_advocate_id" TEXT;
-- CreateTable
CREATE TABLE "client_teams" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "client_teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "client_team_members" (
"id" TEXT NOT NULL,
"team_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "client_team_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "client_teams_client_id_key" ON "client_teams"("client_id");
-- CreateIndex
CREATE UNIQUE INDEX "client_team_members_team_id_user_id_key" ON "client_team_members"("team_id", "user_id");
-- CreateIndex
CREATE INDEX "clients_claims_advocate_id_idx" ON "clients"("claims_advocate_id");
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_claims_advocate_id_fkey" FOREIGN KEY ("claims_advocate_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "client_teams" ADD CONSTRAINT "client_teams_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "client_team_members" ADD CONSTRAINT "client_team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "client_teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "client_team_members" ADD CONSTRAINT "client_team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,46 @@
/*
Warnings:
- You are about to drop the `client_team_members` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `client_teams` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "client_team_members" DROP CONSTRAINT "client_team_members_team_id_fkey";
-- DropForeignKey
ALTER TABLE "client_team_members" DROP CONSTRAINT "client_team_members_user_id_fkey";
-- DropForeignKey
ALTER TABLE "client_teams" DROP CONSTRAINT "client_teams_client_id_fkey";
-- DropTable
DROP TABLE "client_team_members";
-- DropTable
DROP TABLE "client_teams";
-- CreateTable
CREATE TABLE "client_members" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "client_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "client_members_client_id_idx" ON "client_members"("client_id");
-- CreateIndex
CREATE INDEX "client_members_user_id_idx" ON "client_members"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "client_members_client_id_user_id_key" ON "client_members"("client_id", "user_id");
-- AddForeignKey
ALTER TABLE "client_members" ADD CONSTRAINT "client_members_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "client_members" ADD CONSTRAINT "client_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -33,6 +33,9 @@ model User {
auditLogs AuditLog[]
notifications Notification[]
notificationPrefs NotificationPreference[]
createdPolicyGroups PolicyGroup[]
advocateClients Client[] @relation("ClientAdvocate")
clientMemberships ClientMember[]
@@map("users")
}
@ -114,6 +117,7 @@ model Client {
amsModifiedAt DateTime? @map("ams_modified_at")
designationId String? @map("designation_id")
designation2Id String? @map("designation2_id")
claimsAdvocateId String? @map("claims_advocate_id")
notes String? @db.Text
customFields Json @default("{}") @map("custom_fields")
lastSyncedAt DateTime? @map("last_synced_at")
@ -122,19 +126,59 @@ 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])
policies Policy[]
tasks Task[]
policyGroups PolicyGroup[]
members ClientMember[]
@@index([name])
@@index([designationId])
@@index([designation2Id])
@@index([claimsAdvocateId])
@@map("clients")
}
model ClientMember {
id String @id @default(cuid())
clientId String @map("client_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([clientId, userId])
@@index([clientId])
@@index([userId])
@@map("client_members")
}
model PolicyGroup {
id String @id @default(cuid())
clientId String @map("client_id")
name String
renewalDate DateTime @map("renewal_date")
notes String? @db.Text
createdBy String? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
creator User? @relation(fields: [createdBy], references: [id])
policies Policy[]
tasks Task[]
@@index([clientId])
@@index([renewalDate])
@@map("policy_groups")
}
model Policy {
id String @id @default(cuid())
amsPolicyId String @unique @map("ams_policy_id")
clientId String @map("client_id")
policyGroupId String? @map("policy_group_id")
policyNumber String? @map("policy_number")
policyType String? @map("policy_type")
effectiveDate DateTime? @map("effective_date")
@ -158,10 +202,12 @@ model Policy {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
tasks Task[]
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
policyGroup PolicyGroup? @relation(fields: [policyGroupId], references: [id])
tasks Task[]
@@index([clientId])
@@index([policyGroupId])
@@index([expirationDate])
@@index([department])
@@map("policies")
@ -235,6 +281,7 @@ model Task {
priority TaskPriority
clientId String @map("client_id")
policyId String? @map("policy_id")
policyGroupId String? @map("policy_group_id")
templateId String? @map("template_id")
createdBy String? @map("created_by")
completedAt DateTime? @map("completed_at")
@ -246,6 +293,7 @@ model Task {
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
policy Policy? @relation(fields: [policyId], references: [id])
policyGroup PolicyGroup? @relation(fields: [policyGroupId], references: [id])
template TaskTemplate? @relation(fields: [templateId], references: [id])
creator User? @relation("TaskCreatedBy", fields: [createdBy], references: [id])
completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id])
@ -253,6 +301,7 @@ model Task {
@@index([clientId])
@@index([policyId])
@@index([policyGroupId])
@@index([status])
@@index([dueDate])
@@index([department])

View file

@ -0,0 +1,235 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ClipboardList, ChevronLeft, ChevronRight } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SimpleUser { id: string; displayName: string | null; email: string }
interface AuditLogClientProps {
users: SimpleUser[]
}
const ACTION_OPTIONS = [
'_all',
'AUTH_SIGNIN',
'USER_CREATED',
'USER_UPDATED',
'USER_ROLE_CHANGED',
'USER_DELETED',
'TASK_ASSIGNED',
'TASK_UNASSIGNED',
'TASK_STATUS_CHANGED',
'TASK_COMPLETED',
'CLIENT_ADVOCATE_ASSIGNED',
'CLIENT_TEAM_CREATED',
'CLIENT_TEAM_UPDATED',
'CLIENT_TEAM_DELETED',
'CLIENT_TEAM_MEMBER_ADDED',
'CLIENT_TEAM_MEMBER_REMOVED',
'AUTO_GENERATE_TASKS_GROUP',
'AUTO_GENERATE_TASKS_POLICY',
'GENERATE_TASKS_FROM_GROUP',
'UPDATE',
'CREATE',
'DELETE',
'DEACTIVATE',
]
const actionColor = (action: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
if (action.includes('DELETE') || action.includes('REMOVED') || action.includes('UNASSIGNED')) return 'destructive'
if (action.includes('CREATED') || action.includes('ADDED') || action.includes('ASSIGNED')) return 'default'
if (action.includes('SIGNIN')) return 'secondary'
return 'outline'
}
export function AuditLogClient({ users }: AuditLogClientProps) {
const [logs, setLogs] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const [actionFilter, setActionFilter] = useState('_all')
const [userFilter, setUserFilter] = useState('_all')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const fetchLogs = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ page: page.toString(), limit: '50' })
if (actionFilter !== '_all') params.set('action', actionFilter)
if (userFilter !== '_all') params.set('userId', userFilter)
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
const res = await fetch(`/api/admin/audit?${params}`)
const data = await res.json()
setLogs(data.logs || [])
setTotal(data.pagination?.total || 0)
setTotalPages(data.pagination?.totalPages || 1)
} catch {
setLogs([])
} finally {
setLoading(false)
}
}, [page, actionFilter, userFilter, dateFrom, dateTo])
useEffect(() => { fetchLogs() }, [fetchLogs])
const handleFilterChange = () => { setPage(1); fetchLogs() }
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<ClipboardList className="h-8 w-8" />
Audit Log
</h1>
<p className="text-muted-foreground mt-1">
{total} total entries
</p>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Select value={actionFilter} onValueChange={(v) => { setActionFilter(v); setPage(1) }}>
<SelectTrigger><SelectValue placeholder="All actions" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">All actions</SelectItem>
{ACTION_OPTIONS.filter((a) => a !== '_all').map((a) => (
<SelectItem key={a} value={a}>{a}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={userFilter} onValueChange={(v) => { setUserFilter(v); setPage(1) }}>
<SelectTrigger><SelectValue placeholder="All users" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">All users</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
placeholder="From date"
/>
<Input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
placeholder="To date"
/>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardContent className="pt-4">
{loading ? (
<div className="text-center py-12 text-muted-foreground">Loading...</div>
) : logs.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No audit log entries found</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Performed By</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap" suppressHydrationWarning>
{new Date(log.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant={actionColor(log.action)}>{log.action}</Badge>
</TableCell>
<TableCell className="text-sm">
{log.user?.displayName || log.user?.email || <span className="text-muted-foreground">System</span>}
</TableCell>
<TableCell className="text-sm">
<span className="font-medium">{log.entityType}</span>
{log.entityId && (
<span className="text-muted-foreground ml-1 font-mono text-xs">
{log.entityId.slice(0, 8)}
</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[300px]">
{log.newValues && (
<span className="line-clamp-2">{JSON.stringify(log.newValues)}</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,26 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { AuditLogClient } from './page-client'
export const dynamic = 'force-dynamic'
export default async function AuditLogPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) redirect('/dashboard')
const users = await prisma.user.findMany({
select: { id: true, displayName: true, email: true },
orderBy: { displayName: 'asc' },
})
return (
<div className="container mx-auto py-8">
<AuditLogClient users={users} />
</div>
)
}

View file

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Shapes, FileText, Users, Database, Settings } from 'lucide-react'
import { Shapes, FileText, Users, Database, Settings, ClipboardList } from 'lucide-react'
export default async function AdminPage() {
const session = await getServerSession(authOptions)
@ -59,6 +59,14 @@ export default async function AdminPage() {
color: 'text-gray-600',
bgColor: 'bg-gray-100',
},
{
title: 'Audit Log',
description: 'View authentication, task, and user activity history',
icon: ClipboardList,
href: '/admin/audit',
color: 'text-red-600',
bgColor: 'bg-red-100',
},
]
return (

View file

@ -20,31 +20,70 @@ export default async function ClientDetailPage({
const { id } = await params
const client = await prisma.client.findUnique({
where: { id },
include: {
designation: true,
designation2: true,
policies: {
orderBy: { expirationDate: 'desc' },
},
tasks: {
include: {
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
const userRoles = (session.user as any).roles || []
const canManageGroups = userRoles.includes('Admin') || userRoles.includes('Manager')
const [client, designations, policyGroups] = await Promise.all([
prisma.client.findUnique({
where: { id },
include: {
designation: true,
designation2: true,
claimsAdvocate: {
select: { id: true, displayName: true, email: true },
},
members: {
include: {
user: { select: { id: true, displayName: true, email: true } },
},
orderBy: { createdAt: 'asc' as const },
},
policies: {
orderBy: { expirationDate: 'desc' },
},
tasks: {
include: {
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
},
orderBy: { dueDate: 'asc' },
},
},
})
}),
prisma.designation.findMany({
where: { isActive: true },
orderBy: { displayOrder: 'asc' },
}),
prisma.policyGroup.findMany({
where: { clientId: id },
include: {
policies: {
select: {
id: true,
policyNumber: true,
policyType: true,
expirationDate: true,
department: true,
carrierName: true,
writingCompanyName: true,
},
},
creator: {
select: { displayName: true, email: true },
},
_count: { select: { tasks: true } },
},
orderBy: { renewalDate: 'asc' },
}),
])
if (!client) {
redirect('/clients')
@ -59,14 +98,14 @@ export default async function ClientDetailPage({
})),
}
const designations = await prisma.designation.findMany({
where: { isActive: true },
orderBy: { displayOrder: 'asc' },
})
return (
<div className="container mx-auto py-8">
<ClientDetail client={clientData} designations={designations} />
<ClientDetail
client={clientData}
designations={designations}
policyGroups={policyGroups}
canManageGroups={canManageGroups}
/>
</div>
)
}

View file

@ -0,0 +1,288 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Users, Filter, CheckSquare } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SimpleUser { id: string; displayName: string | null; email: string }
interface SimpleClient { id: string; name: string }
interface SimpleDesignation { id: string; name: string }
interface BulkAssignClientProps {
users: SimpleUser[]
clients: SimpleClient[]
designations: SimpleDesignation[]
}
const STATUS_OPTIONS = [
{ value: '_all', label: 'All statuses' },
{ value: 'NOT_STARTED', label: 'Not Started' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'BLOCKED', label: 'Blocked' },
]
const DEPT_OPTIONS = [
{ value: '_all', label: 'All departments' },
{ value: 'PERSONAL_LINES', label: 'Personal Lines' },
{ value: 'COMMERCIAL_LINES', label: 'Commercial Lines' },
{ value: 'CLAIMS', label: 'Claims' },
{ value: 'BENEFITS', label: 'Benefits' },
{ value: 'OTHER', label: 'Other' },
]
export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) {
const [tasks, setTasks] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [assignTo, setAssignTo] = useState('')
const [assigning, setAssigning] = useState(false)
// Filters
const [clientFilter, setClientFilter] = useState('_all')
const [designationFilter, setDesignationFilter] = useState('_all')
const [statusFilter, setStatusFilter] = useState('NOT_STARTED')
const [deptFilter, setDeptFilter] = useState('_all')
const fetchTasks = useCallback(async () => {
setLoading(true)
setSelected(new Set())
try {
const params = new URLSearchParams({ limit: '200' })
if (clientFilter !== '_all') params.set('clientId', clientFilter)
if (designationFilter !== '_all') params.set('designationId', designationFilter)
if (statusFilter !== '_all') params.set('status', statusFilter)
if (deptFilter !== '_all') params.set('department', deptFilter)
const res = await fetch(`/api/tasks?${params}`)
const data = await res.json()
setTasks(data.tasks || [])
} catch {
toast.error('Failed to load tasks')
} finally {
setLoading(false)
}
}, [clientFilter, designationFilter, statusFilter, deptFilter])
useEffect(() => { fetchTasks() }, [fetchTasks])
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleAll = () => {
if (selected.size === tasks.length) {
setSelected(new Set())
} else {
setSelected(new Set(tasks.map((t) => t.id)))
}
}
const handleAssign = async () => {
if (!assignTo || selected.size === 0) return
setAssigning(true)
try {
const res = await fetch('/api/tasks/bulk-assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskIds: Array.from(selected), userId: assignTo }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
toast.success(`Assigned ${data.assigned} task(s)${data.skipped > 0 ? ` (${data.skipped} already assigned)` : ''}`)
setSelected(new Set())
fetchTasks()
} catch (err: any) {
toast.error(err.message || 'Failed to assign tasks')
} finally {
setAssigning(false)
}
}
const statusColor: Record<string, string> = {
NOT_STARTED: 'secondary',
IN_PROGRESS: 'default',
COMPLETED: 'default',
BLOCKED: 'destructive',
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Users className="h-8 w-8" />
Bulk Task Assignment
</h1>
<p className="text-muted-foreground mt-1">Filter tasks and assign them to a team member</p>
</div>
{/* Filter bar */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Filter className="h-4 w-4" />
Filters
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Select value={clientFilter} onValueChange={setClientFilter}>
<SelectTrigger><SelectValue placeholder="All clients" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">All clients</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={designationFilter} onValueChange={setDesignationFilter}>
<SelectTrigger><SelectValue placeholder="All designations" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">All designations</SelectItem>
{designations.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
<Select value={deptFilter} onValueChange={setDeptFilter}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DEPT_OPTIONS.map((d) => <SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Assignment action bar */}
{selected.size > 0 && (
<Card className="border-primary">
<CardContent className="pt-4">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium">
<CheckSquare className="inline h-4 w-4 mr-1" />
{selected.size} task{selected.size !== 1 ? 's' : ''} selected
</span>
<Select value={assignTo || '_none'} onValueChange={(v) => setAssignTo(v === '_none' ? '' : v)}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Assign to..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select user</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleAssign} disabled={!assignTo || assigning}>
{assigning ? 'Assigning...' : 'Assign'}
</Button>
<Button variant="ghost" onClick={() => setSelected(new Set())}>
Clear selection
</Button>
</div>
</CardContent>
</Card>
)}
{/* Tasks table */}
<Card>
<CardContent className="pt-4">
{loading ? (
<div className="text-center py-12 text-muted-foreground">Loading tasks...</div>
) : tasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No tasks match the current filters</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selected.size === tasks.length && tasks.length > 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead>Task</TableHead>
<TableHead>Client</TableHead>
<TableHead>Department</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned To</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TableRow
key={task.id}
className={selected.has(task.id) ? 'bg-muted/50' : ''}
onClick={() => toggleSelect(task.id)}
style={{ cursor: 'pointer' }}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(task.id)}
onCheckedChange={() => toggleSelect(task.id)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{task.title}</div>
{task.description && (
<div className="text-xs text-muted-foreground line-clamp-1">{task.description}</div>
)}
</TableCell>
<TableCell className="text-sm">{task.client?.name || '—'}</TableCell>
<TableCell className="text-sm">{task.department?.replace('_', ' ') || '—'}</TableCell>
<TableCell className="text-sm">
<span suppressHydrationWarning>{formatDate(task.dueDate)}</span>
</TableCell>
<TableCell>
<Badge variant={(statusColor[task.status] as any) || 'secondary'}>
{task.status?.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-sm">
{task.assignments?.length > 0
? task.assignments.map((a: any) => a.user?.displayName || a.user?.email).join(', ')
: <span className="text-muted-foreground">Unassigned</span>
}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,40 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { BulkAssignClient } from './page-client'
export const dynamic = 'force-dynamic'
export default async function BulkAssignPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
redirect('/tasks')
}
const [users, clients, designations] = await Promise.all([
prisma.user.findMany({
where: { isActive: true },
select: { id: true, displayName: true, email: true },
orderBy: { displayName: 'asc' },
}),
prisma.client.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
prisma.designation.findMany({
where: { isActive: true },
select: { id: true, name: true },
orderBy: { displayOrder: 'asc' },
}),
])
return (
<div className="container mx-auto py-8">
<BulkAssignClient users={users} clients={clients} designations={designations} />
</div>
)
}

View file

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* GET /api/admin/audit
* Returns paginated audit log entries. Admin only.
* Query params: action, userId, dateFrom, dateTo, page, limit
*/
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || ''
const userId = searchParams.get('userId') || ''
const dateFrom = searchParams.get('dateFrom') || ''
const dateTo = searchParams.get('dateTo') || ''
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const where: any = {}
if (action) where.action = action
if (userId) where.userId = userId
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) {
const end = new Date(dateTo)
end.setHours(23, 59, 59, 999)
where.createdAt.lte = end
}
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: { id: true, displayName: true, email: true },
},
},
}),
prisma.auditLog.count({ where }),
])
return NextResponse.json({
logs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Audit log API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* GET /api/clients/[id]/policy-groups
* List all policy groups for a client
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
const groups = await prisma.policyGroup.findMany({
where: { clientId },
include: {
policies: {
select: {
id: true,
policyNumber: true,
policyType: true,
expirationDate: true,
department: true,
carrierName: true,
writingCompanyName: true,
},
},
creator: {
select: { displayName: true, email: true },
},
_count: { select: { tasks: true } },
},
orderBy: { renewalDate: 'asc' },
})
return NextResponse.json(groups)
} catch (error) {
console.error('Policy groups GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/clients/[id]/policy-groups
* Create a new policy group for a client
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id: clientId } = await params
const body = await request.json()
const { name, renewalDate, notes, policyIds } = body
if (!name || !renewalDate) {
return NextResponse.json(
{ error: 'name and renewalDate are required' },
{ status: 400 }
)
}
const client = await prisma.client.findUnique({ where: { id: clientId } })
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
if (policyIds && policyIds.length > 0) {
const alreadyAssigned = await prisma.policy.findMany({
where: {
id: { in: policyIds },
policyGroupId: { not: null },
},
select: { id: true, policyNumber: true, policyGroupId: true },
})
if (alreadyAssigned.length > 0) {
return NextResponse.json(
{
error: 'Some policies are already assigned to a group',
conflicting: alreadyAssigned.map((p) => p.policyNumber || p.id),
},
{ status: 409 }
)
}
}
const group = await prisma.policyGroup.create({
data: {
clientId,
name,
renewalDate: new Date(renewalDate),
notes: notes || null,
createdBy: (session.user as any).id,
policies: policyIds && policyIds.length > 0
? { connect: policyIds.map((pid: string) => ({ id: pid })) }
: undefined,
},
include: {
policies: {
select: {
id: true,
policyNumber: true,
policyType: true,
expirationDate: true,
department: true,
carrierName: true,
writingCompanyName: true,
},
},
creator: {
select: { displayName: true, email: true },
},
_count: { select: { tasks: true } },
},
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CREATE_POLICY_GROUP',
entityType: 'PolicyGroup',
entityId: group.id,
newValues: { name, renewalDate, policyIds },
},
})
return NextResponse.json(group, { status: 201 })
} catch (error) {
console.error('Policy groups POST error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -29,6 +29,18 @@ export async function GET(
include: {
designation: true,
designation2: true,
claimsAdvocate: {
select: { id: true, displayName: true, email: true },
},
team: {
include: {
members: {
include: {
user: { select: { id: true, displayName: true, email: true } },
},
},
},
},
policies: {
orderBy: { expirationDate: 'desc' },
},
@ -106,32 +118,57 @@ export async function PATCH(
const { id } = await params
const body = await request.json()
const { designationId, designation2Id, notes, customFields } = body
const { designationId, designation2Id, claimsAdvocateId, notes, customFields } = body
const existing = await prisma.client.findUnique({
where: { id },
select: { claimsAdvocateId: true },
})
const client = await prisma.client.update({
where: { id },
data: {
...(designationId !== undefined && { designationId }),
...(designation2Id !== undefined && { designation2Id }),
...(claimsAdvocateId !== undefined && { claimsAdvocateId: claimsAdvocateId || null }),
...(notes !== undefined && { notes }),
...(customFields !== undefined && { customFields }),
},
include: {
designation: true,
designation2: true,
claimsAdvocate: { select: { id: true, displayName: true, email: true } },
},
})
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'Client',
entityId: client.id,
newValues: { designationId, designation2Id, notes, customFields },
},
})
const auditEntries: Promise<any>[] = [
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'Client',
entityId: client.id,
newValues: { designationId, designation2Id, notes, customFields },
},
}),
]
if (claimsAdvocateId !== undefined && claimsAdvocateId !== existing?.claimsAdvocateId) {
auditEntries.push(
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CLIENT_ADVOCATE_ASSIGNED',
entityType: 'Client',
entityId: client.id,
oldValues: { claimsAdvocateId: existing?.claimsAdvocateId },
newValues: { claimsAdvocateId },
},
})
)
}
await Promise.all(auditEntries)
return NextResponse.json(client)
} catch (error) {

View file

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* GET /api/clients/[id]/team
* Get all members assigned to this client
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
const members = await prisma.clientMember.findMany({
where: { clientId },
include: {
user: { select: { id: true, displayName: true, email: true, department: true } },
},
orderBy: { createdAt: 'asc' },
})
return NextResponse.json({ members })
} catch (error) {
console.error('Team GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/clients/[id]/team
* Add a user to this client's team
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id: clientId } = await params
const { userId } = await request.json()
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
}
const member = await prisma.clientMember.create({
data: { clientId, userId },
include: {
user: { select: { id: true, displayName: true, email: true, department: true } },
},
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CLIENT_MEMBER_ADDED',
entityType: 'Client',
entityId: clientId,
newValues: { userId },
},
})
return NextResponse.json(member, { status: 201 })
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: 'User is already a team member' }, { status: 409 })
}
console.error('Team POST error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/clients/[id]/team
* Remove a user from this client's team (userId in body)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id: clientId } = await params
const { userId } = await request.json().catch(() => ({}))
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
}
await prisma.clientMember.deleteMany({ where: { clientId, userId } })
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CLIENT_MEMBER_REMOVED',
entityType: 'Client',
entityId: clientId,
oldValues: { userId },
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Team DELETE error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -26,6 +26,8 @@ export async function GET(request: NextRequest) {
const designationId = searchParams.get('designationId') || ''
const designation2Id = searchParams.get('designation2Id') || ''
const department = searchParams.get('department') || ''
const advocateId = searchParams.get('advocateId') || ''
const teamMemberId = searchParams.get('teamMemberId') || ''
const sortBy = searchParams.get('sortBy') || 'name'
const sortOrder = searchParams.get('sortOrder') || 'asc'
@ -50,20 +52,66 @@ export async function GET(request: NextRequest) {
where.designation2Id = designation2Id
}
if (advocateId) {
where.claimsAdvocateId = advocateId
}
if (teamMemberId) {
// Look up the user's display name to match against policy executive/CSR fields
const teamMemberUser = await prisma.user.findUnique({
where: { id: teamMemberId },
select: { displayName: true },
})
const displayName = teamMemberUser?.displayName || ''
// Policies store names as "Last, First" — convert "First Last" to "Last, First"
const nameParts = displayName.trim().split(/\s+/)
const reversedName = nameParts.length >= 2
? `${nameParts[nameParts.length - 1]}, ${nameParts.slice(0, -1).join(' ')}`
: displayName
// Build name search conditions covering both "First Last" and "Last, First" formats
const nameConditions = [displayName, reversedName]
.filter(Boolean)
.flatMap((name) => [
{ policies: { some: { executiveName: { contains: name, mode: 'insensitive' as const } } } },
{ policies: { some: { csrName: { contains: name, mode: 'insensitive' as const } } } },
])
where.OR = [
// Assigned directly as a client member
{
members: {
some: { userId: teamMemberId },
},
},
// Named as executive or CSR on any policy (either name format)
...nameConditions,
]
}
// RBAC filtering - non-managers see only assigned clients
const userRoles = (session.user as any).roles || []
const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager')
if (!isManagerOrAdmin) {
// Filter to clients where user is assigned via policy personnel
where.policies = {
some: {
OR: [
{ executiveName: { contains: (session.user as any).displayName || '' } },
{ csrName: { contains: (session.user as any).displayName || '' } },
],
const rbacFilter = {
policies: {
some: {
OR: [
{ executiveName: { contains: (session.user as any).displayName || '' } },
{ csrName: { contains: (session.user as any).displayName || '' } },
],
},
},
}
// If teamMemberId already set an OR clause, combine with AND so both must apply
if (where.OR) {
where.AND = [{ OR: where.OR }, rbacFilter]
delete where.OR
} else {
where.policies = rbacFilter.policies
}
}
// Build order by
@ -110,8 +158,8 @@ export async function GET(request: NextRequest) {
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Clients API error:', error)
} catch (error: any) {
console.error('Clients API error:', error?.message || error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }

View file

@ -0,0 +1,237 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
const TWENTY_DAYS_MS = 20 * 24 * 60 * 60 * 1000
/**
* POST /api/cron/auto-generate
* Called by a cron scheduler. Protected by CRON_SECRET header.
*
* Rules:
* - Policy must be >= 20 days old in the system
* - If policy is in a group, use group renewalDate; otherwise use policy expirationDate
* - Skip policies/groups that already have tasks generated from templates
* - Auto-assign to client's claimsAdvocate if set
*/
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-cron-secret')
if (!secret || secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const killSwitch = await prisma.syncConfig.findUnique({
where: { key: 'task_auto_generate_enabled' },
})
if (killSwitch?.value === 'false') {
return NextResponse.json({ message: 'Auto-generate disabled via SyncConfig' })
}
const cutoff = new Date(Date.now() - TWENTY_DAYS_MS)
let groupTasksCreated = 0
let policyTasksCreated = 0
let errors: string[] = []
try {
// ─── 1. Policy Groups ───────────────────────────────────────────────────
// Find groups whose policies are all >= 20 days old and have no template-generated tasks yet
const groups = await prisma.policyGroup.findMany({
where: {
policies: {
every: { createdAt: { lte: cutoff } },
some: {}, // group must have at least one policy
},
tasks: {
none: { templateId: { not: null } },
},
},
include: {
client: {
select: {
designationId: true,
designation2Id: true,
claimsAdvocateId: true,
},
},
},
})
for (const group of groups) {
try {
const designationIds = [
group.client.designationId,
group.client.designation2Id,
].filter(Boolean) as string[]
const templates = await prisma.taskTemplate.findMany({
where: {
isActive: true,
OR: [
{ designationId: null },
...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
],
},
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
})
if (templates.length === 0) continue
const renewalDate = new Date(group.renewalDate)
const tasksToCreate = templates.map((template) => {
const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: group.clientId,
policyGroupId: group.id,
templateId: template.id,
}
})
const created = await prisma.task.createMany({ data: tasksToCreate })
groupTasksCreated += created.count
// Auto-assign to claims advocate
if (group.client.claimsAdvocateId && created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyGroupId: group.id, templateId: { in: templates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({
taskId: t.id,
userId: group.client.claimsAdvocateId!,
})),
skipDuplicates: true,
})
}
}
await prisma.auditLog.create({
data: {
action: 'AUTO_GENERATE_TASKS_GROUP',
entityType: 'PolicyGroup',
entityId: group.id,
newValues: { tasksCreated: created.count, renewalDate: group.renewalDate },
},
})
} catch (err: any) {
errors.push(`Group ${group.id}: ${err.message}`)
}
}
// ─── 2. Individual Policies (not in a group) ────────────────────────────
const policies = await prisma.policy.findMany({
where: {
createdAt: { lte: cutoff },
policyGroupId: null,
tasks: {
none: { templateId: { not: null } },
},
},
include: {
client: {
select: {
designationId: true,
designation2Id: true,
claimsAdvocateId: true,
},
},
},
})
for (const policy of policies) {
try {
const designationIds = [
policy.client.designationId,
policy.client.designation2Id,
].filter(Boolean) as string[]
const templates = await prisma.taskTemplate.findMany({
where: {
isActive: true,
OR: [
{ designationId: null },
...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
],
},
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
})
if (templates.length === 0) continue
const anchorDate = new Date(policy.expirationDate)
const tasksToCreate = templates.map((template) => {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: policy.clientId,
policyId: policy.id,
templateId: template.id,
}
})
const created = await prisma.task.createMany({ data: tasksToCreate })
policyTasksCreated += created.count
// Auto-assign to claims advocate
if (policy.client.claimsAdvocateId && created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyId: policy.id, templateId: { in: templates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({
taskId: t.id,
userId: policy.client.claimsAdvocateId!,
})),
skipDuplicates: true,
})
}
}
await prisma.auditLog.create({
data: {
action: 'AUTO_GENERATE_TASKS_POLICY',
entityType: 'Policy',
entityId: policy.id,
newValues: { tasksCreated: created.count, expirationDate: policy.expirationDate },
},
})
} catch (err: any) {
errors.push(`Policy ${policy.id}: ${err.message}`)
}
}
return NextResponse.json({
groupTasksCreated,
policyTasksCreated,
totalCreated: groupTasksCreated + policyTasksCreated,
groupsProcessed: groups.length,
policiesProcessed: policies.length,
errors: errors.length > 0 ? errors : undefined,
})
} catch (error: any) {
console.error('Auto-generate cron error:', error)
return NextResponse.json({ error: 'Internal server error', detail: error.message }, { status: 500 })
}
}

View file

@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/policy-groups/[id]/generate-tasks
* Generate tasks from active templates using the group's renewalDate.
* One task per template per group (skips already-generated ones).
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const group = await prisma.policyGroup.findUnique({
where: { id },
include: {
client: {
include: {
designation: true,
designation2: true,
},
},
tasks: {
select: { templateId: true },
},
},
})
if (!group) {
return NextResponse.json({ error: 'Policy group not found' }, { status: 404 })
}
const alreadyGeneratedTemplateIds = group.tasks
.map((t) => t.templateId)
.filter(Boolean) as string[]
const designationIds = [
group.client.designationId,
group.client.designation2Id,
].filter(Boolean) as string[]
const templates = await prisma.taskTemplate.findMany({
where: {
isActive: true,
id: { notIn: alreadyGeneratedTemplateIds },
OR: [
{ designationId: null },
...(designationIds.length > 0
? [{ designationId: { in: designationIds } }]
: []),
],
},
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
})
if (templates.length === 0) {
return NextResponse.json({
created: 0,
skipped: alreadyGeneratedTemplateIds.length,
message: 'No new templates to generate tasks from',
})
}
const renewalDate = new Date(group.renewalDate)
const tasksToCreate = templates.map((template) => {
const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: group.clientId,
policyGroupId: group.id,
templateId: template.id,
createdBy: (session.user as any).id,
}
})
const result = await prisma.task.createMany({
data: tasksToCreate,
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'GENERATE_TASKS_FROM_GROUP',
entityType: 'PolicyGroup',
entityId: group.id,
newValues: {
tasksCreated: result.count,
templatesUsed: templates.map((t) => t.name),
renewalDate: group.renewalDate,
},
},
})
return NextResponse.json({
created: result.count,
skipped: alreadyGeneratedTemplateIds.length,
templates: templates.map((t) => ({
name: t.name,
dueDate: new Date(
new Date(group.renewalDate).setDate(
new Date(group.renewalDate).getDate() + t.daysOffset
)
),
})),
})
} catch (error) {
console.error('Generate tasks error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,234 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* GET /api/policy-groups/[id]
* Get a single policy group with its policies and tasks
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const group = await prisma.policyGroup.findUnique({
where: { id },
include: {
policies: {
select: {
id: true,
policyNumber: true,
policyType: true,
expirationDate: true,
department: true,
carrierName: true,
writingCompanyName: true,
},
},
tasks: {
include: {
assignments: {
include: {
user: { select: { displayName: true, email: true } },
},
},
template: { select: { name: true } },
},
orderBy: { dueDate: 'asc' },
},
creator: {
select: { displayName: true, email: true },
},
_count: { select: { tasks: true } },
},
})
if (!group) {
return NextResponse.json({ error: 'Policy group not found' }, { status: 404 })
}
return NextResponse.json(group)
} catch (error) {
console.error('Policy group GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PATCH /api/policy-groups/[id]
* Update a policy group (name, renewalDate, notes, add/remove policies)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const { name, renewalDate, notes, policyIds } = body
const existing = await prisma.policyGroup.findUnique({
where: { id },
include: { policies: { select: { id: true } } },
})
if (!existing) {
return NextResponse.json({ error: 'Policy group not found' }, { status: 404 })
}
if (policyIds !== undefined) {
const currentPolicyIds = existing.policies.map((p) => p.id)
const newPolicyIds: string[] = policyIds
const addingIds = newPolicyIds.filter((pid) => !currentPolicyIds.includes(pid))
if (addingIds.length > 0) {
const alreadyAssigned = await prisma.policy.findMany({
where: {
id: { in: addingIds },
policyGroupId: { not: null },
},
select: { id: true, policyNumber: true },
})
if (alreadyAssigned.length > 0) {
return NextResponse.json(
{
error: 'Some policies are already assigned to a group',
conflicting: alreadyAssigned.map((p) => p.policyNumber || p.id),
},
{ status: 409 }
)
}
}
}
const group = await prisma.policyGroup.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(renewalDate !== undefined && { renewalDate: new Date(renewalDate) }),
...(notes !== undefined && { notes }),
...(policyIds !== undefined && {
policies: {
set: policyIds.map((pid: string) => ({ id: pid })),
},
}),
},
include: {
policies: {
select: {
id: true,
policyNumber: true,
policyType: true,
expirationDate: true,
department: true,
carrierName: true,
writingCompanyName: true,
},
},
creator: {
select: { displayName: true, email: true },
},
_count: { select: { tasks: true } },
},
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE_POLICY_GROUP',
entityType: 'PolicyGroup',
entityId: group.id,
oldValues: { name: existing.name, renewalDate: existing.renewalDate },
newValues: { name, renewalDate, notes, policyIds },
},
})
return NextResponse.json(group)
} catch (error) {
console.error('Policy group PATCH error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/policy-groups/[id]
* Delete a policy group (unlinks policies, cancels pending tasks)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const existing = await prisma.policyGroup.findUnique({
where: { id },
include: { _count: { select: { tasks: true } } },
})
if (!existing) {
return NextResponse.json({ error: 'Policy group not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const cancelTasks = searchParams.get('cancelTasks') === 'true'
if (cancelTasks) {
await prisma.task.updateMany({
where: {
policyGroupId: id,
status: { in: ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] },
},
data: {
status: 'CANCELLED',
cancelledReason: 'Policy group deleted',
},
})
}
await prisma.policyGroup.delete({ where: { id } })
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'DELETE_POLICY_GROUP',
entityType: 'PolicyGroup',
entityId: id,
oldValues: { name: existing.name, renewalDate: existing.renewalDate },
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Policy group DELETE error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/tasks/bulk-assign
* Assign a list of tasks to a user (adds to existing assignments).
* Body: { taskIds: string[], userId: string }
* Requires Admin or Manager role.
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { taskIds, userId } = body
if (!Array.isArray(taskIds) || taskIds.length === 0) {
return NextResponse.json({ error: 'taskIds must be a non-empty array' }, { status: 400 })
}
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
}
const targetUser = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, displayName: true, isActive: true },
})
if (!targetUser || !targetUser.isActive) {
return NextResponse.json({ error: 'User not found or inactive' }, { status: 404 })
}
const result = await prisma.taskAssignment.createMany({
data: taskIds.map((taskId: string) => ({ taskId, userId })),
skipDuplicates: true,
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'TASK_ASSIGNED',
entityType: 'Task',
newValues: {
taskIds,
assignedTo: userId,
assignedToName: targetUser.displayName,
count: result.count,
},
},
})
return NextResponse.json({
assigned: result.count,
skipped: taskIds.length - result.count,
assignedTo: targetUser,
})
} catch (error) {
console.error('Bulk assign error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -14,6 +14,8 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const clientId = searchParams.get('clientId')
const department = searchParams.get('department')
const designationId = searchParams.get('designationId')
const assignedToMe = searchParams.get('assignedToMe') === 'true'
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
@ -22,6 +24,15 @@ export async function GET(request: NextRequest) {
if (status) where.status = status
if (clientId) where.clientId = clientId
if (department) where.department = department
if (designationId) {
where.client = {
OR: [
{ designationId },
{ designation2Id: designationId },
],
}
}
if (assignedToMe) {
where.assignments = {
some: {

View file

@ -132,22 +132,42 @@ export async function PATCH(
})
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'User',
entityId: id,
oldValues: {
email: oldUser.email,
displayName: oldUser.displayName,
department: oldUser.department,
isActive: oldUser.isActive,
roleIds: oldUser.userRoles.map((ur) => ur.roleId),
const oldRoleIds = oldUser.userRoles.map((ur) => ur.roleId)
const auditEntries: Promise<any>[] = [
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'USER_UPDATED',
entityType: 'User',
entityId: id,
oldValues: {
email: oldUser.email,
displayName: oldUser.displayName,
department: oldUser.department,
isActive: oldUser.isActive,
},
newValues: { email, displayName, department, isActive },
},
newValues: { email, displayName, department, isActive, roleIds },
},
})
}),
]
// Separate role change audit entry
if (roleIds !== undefined) {
auditEntries.push(
prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'USER_ROLE_CHANGED',
entityType: 'User',
entityId: id,
oldValues: { roleIds: oldRoleIds },
newValues: { roleIds },
},
})
)
}
await Promise.all(auditEntries)
return NextResponse.json(user)
} catch (error) {
@ -186,7 +206,7 @@ export async function DELETE(
include: {
_count: {
select: {
assignedTasks: true,
taskAssignments: true,
createdTasks: true,
},
},
@ -198,7 +218,7 @@ export async function DELETE(
}
// If user has associated data, soft delete by deactivating
if (user._count.assignedTasks > 0 || user._count.createdTasks > 0) {
if (user._count.taskAssignments > 0 || user._count.createdTasks > 0) {
await prisma.user.update({
where: { id },
data: { isActive: false },

View file

@ -43,6 +43,11 @@ export async function GET(request: NextRequest) {
where.isActive = isActive === 'true'
}
const department = searchParams.get('department')
if (department) {
where.department = { equals: department, mode: 'insensitive' }
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
@ -133,7 +138,7 @@ export async function POST(request: NextRequest) {
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CREATE',
action: 'USER_CREATED',
entityType: 'User',
entityId: user.id,
newValues: { email, displayName, department, roleIds },

View file

@ -86,7 +86,7 @@ export function ClientCard({ client }: ClientCardProps) {
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Next expiration:</span>
<span className="font-medium">
<span className="font-medium" suppressHydrationWarning>
{formatDate(nextPolicy.expirationDate)}
</span>
{daysToExpiration !== null && daysToExpiration <= 90 && (

View file

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -13,19 +13,100 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare } from 'lucide-react'
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck } from 'lucide-react'
import { Combobox } from '@/components/ui/combobox'
import { formatDate } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { toast } from 'sonner'
interface SimpleUser {
id: string
displayName: string | null
email: string
}
interface ClientDetailProps {
client: any
designations: any[]
policyGroups?: any[]
canManageGroups?: boolean
}
export function ClientDetail({ client, designations }: ClientDetailProps) {
export function ClientDetail({ client, designations, policyGroups = [], canManageGroups = false }: ClientDetailProps) {
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
// Assignments state
const [allUsers, setAllUsers] = useState<SimpleUser[]>([])
const [claimsUsers, setClaimsUsers] = useState<SimpleUser[]>([])
const [advocateId, setAdvocateId] = useState<string>(client.claimsAdvocate?.id || '')
const [advocateSaving, setAdvocateSaving] = useState(false)
const [members, setMembers] = useState<any[]>(client.members || [])
const [addMemberId, setAddMemberId] = useState<string>('')
useEffect(() => {
fetch('/api/users?isActive=true&limit=200')
.then((r) => r.json())
.then((d) => setAllUsers(d.users || []))
.catch(() => {})
fetch('/api/users?isActive=true&limit=200&department=Claims')
.then((r) => r.json())
.then((d) => setClaimsUsers(d.users || []))
.catch(() => {})
}, [])
const handleAdvocateSave = async () => {
setAdvocateSaving(true)
try {
const res = await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claimsAdvocateId: advocateId || null }),
})
if (!res.ok) throw new Error()
toast.success('Claims advocate updated')
} catch {
toast.error('Failed to update advocate')
} finally {
setAdvocateSaving(false)
}
}
const handleAddMember = async () => {
if (!addMemberId) return
try {
const res = await fetch(`/api/clients/${client.id}/team`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: addMemberId }),
})
if (res.status === 409) { toast.error('Already a member'); return }
if (!res.ok) throw new Error()
const member = await res.json()
setMembers((prev) => [...prev, member])
setAddMemberId('')
toast.success('Member added')
} catch {
toast.error('Failed to add member')
}
}
const handleRemoveMember = async (userId: string) => {
try {
const res = await fetch(`/api/clients/${client.id}/team`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
})
if (!res.ok) throw new Error()
setMembers((prev) => prev.filter((m: any) => m.userId !== userId))
toast.success('Member removed')
} catch {
toast.error('Failed to remove member')
}
}
const handleDesignationUpdate = async () => {
setSaving(true)
try {
@ -76,6 +157,75 @@ export function ClientDetail({ client, designations }: ClientDetailProps) {
</div>
</div>
{/* Claims Assignments */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="h-5 w-5" />
Claims Assignment
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Advocate */}
<div>
<label className="text-sm font-medium mb-2 block">Claims Advocate</label>
<div className="flex gap-2">
<Combobox
className="flex-1"
options={claimsUsers.map((u) => ({ value: u.id, label: u.displayName || u.email }))}
value={advocateId}
onChange={setAdvocateId}
placeholder="Select advocate (Claims dept)"
emptyText="No Claims staff found"
/>
<Button onClick={handleAdvocateSave} disabled={advocateSaving}>
{advocateSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
{/* Team Members */}
<div>
<label className="text-sm font-medium mb-2 block">Team Members</label>
<div className="space-y-2">
{members.length === 0 && (
<p className="text-xs text-muted-foreground">No members assigned</p>
)}
{members.map((m: any) => (
<div key={m.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{m.user?.displayName || m.user?.email}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveMember(m.userId)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex gap-2 pt-1">
<Combobox
className="flex-1"
options={allUsers
.filter((u) => !members.some((m: any) => m.userId === u.id))
.map((u) => ({ value: u.id, label: u.displayName || u.email }))}
value={addMemberId}
onChange={setAddMemberId}
placeholder="Add member..."
/>
<Button variant="outline" onClick={handleAddMember} disabled={!addMemberId}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Designation Assignment */}
<Card>
<CardHeader>
@ -131,6 +281,10 @@ export function ClientDetail({ client, designations }: ClientDetailProps) {
<CheckSquare className="h-4 w-4 mr-2" />
Tasks ({client.tasks.length})
</TabsTrigger>
<TabsTrigger value="renewal-groups">
<CalendarRange className="h-4 w-4 mr-2" />
Renewal Groups ({policyGroups.length})
</TabsTrigger>
</TabsList>
<TabsContent value="policies" className="space-y-4">
@ -230,24 +384,41 @@ export function ClientDetail({ client, designations }: ClientDetailProps) {
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
{client.tasks.map((task: any) => (
<Card key={task.id}>
<CardContent className="pt-6">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold">{task.title}</h3>
<p className="text-sm text-muted-foreground">{task.description}</p>
</div>
<div className="text-right">
<Badge>{task.status}</Badge>
<p className="text-sm text-muted-foreground mt-1">
Due: {formatDate(task.dueDate)}
</p>
</div>
</div>
{client.tasks.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
No tasks found
</CardContent>
</Card>
))}
) : (
client.tasks.map((task: any) => (
<Card key={task.id}>
<CardContent className="pt-6">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold">{task.title}</h3>
<p className="text-sm text-muted-foreground">{task.description}</p>
</div>
<div className="text-right">
<Badge>{task.status}</Badge>
<p className="text-sm text-muted-foreground mt-1" suppressHydrationWarning>
Due: {formatDate(task.dueDate)}
</p>
</div>
</div>
</CardContent>
</Card>
))
)}
</TabsContent>
<TabsContent value="renewal-groups">
<PolicyGroupManager
clientId={client.id}
initialGroups={policyGroups}
allPolicies={client.policies}
canManage={canManageGroups}
/>
</TabsContent>
</Tabs>
</div>

View file

@ -13,10 +13,16 @@ import {
} from '@/components/ui/select'
import { ClientCard } from './client-card'
import { ClientTable } from './client-table'
import { Search, Filter, LayoutGrid, List } from 'lucide-react'
import { Search, Filter, LayoutGrid, List, X } from 'lucide-react'
type ViewMode = 'cards' | 'table'
interface SimpleUser {
id: string
displayName: string | null
email: string
}
interface ClientWithRelations extends Client {
designation: Designation | null
designation2: Designation | null
@ -37,9 +43,12 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [designationFilter, setDesignationFilter] = useState<string>('')
const [advocateFilter, setAdvocateFilter] = useState<string>('')
const [teamMemberFilter, setTeamMemberFilter] = useState<string>('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [users, setUsers] = useState<SimpleUser[]>([])
const fetchClients = async () => {
setLoading(true)
@ -49,13 +58,20 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
limit: '20',
...(search && { search }),
...(designationFilter && { designationId: designationFilter }),
...(advocateFilter && { advocateId: advocateFilter }),
...(teamMemberFilter && { teamMemberId: teamMemberFilter }),
})
const response = await fetch(`/api/clients?${params}`)
const data = await response.json()
setClients(data.clients)
setTotalPages(data.pagination.totalPages)
if (!response.ok) {
console.error('Clients API error:', data.error, data.detail)
return
}
setClients(data.clients ?? [])
setTotalPages(data.pagination?.totalPages ?? 1)
} catch (error) {
console.error('Failed to fetch clients:', error)
} finally {
@ -63,25 +79,34 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
}
}
useEffect(() => {
fetch('/api/users?isActive=true&limit=200')
.then((r) => r.json())
.then((d) => setUsers(d.users || []))
.catch(() => {})
}, [])
useEffect(() => {
fetchClients()
}, [page, search, designationFilter])
}, [page, search, designationFilter, advocateFilter, teamMemberFilter])
const handleSearchChange = (value: string) => {
setSearch(value)
setPage(1)
}
const handleDesignationFilterChange = (value: string) => {
setDesignationFilter(value)
const handleFilterChange = (setter: (v: string) => void) => (value: string) => {
setter(value === '_all' ? '' : value)
setPage(1)
}
const hasActiveFilters = designationFilter || advocateFilter || teamMemberFilter
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-4">
<div className="flex-1">
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
@ -92,19 +117,55 @@ export function ClientList({ initialClients = [], designations = [] }: ClientLis
/>
</div>
</div>
<Select value={designationFilter || undefined} onValueChange={handleDesignationFilterChange}>
<SelectTrigger className="w-[200px]">
<Select value={designationFilter || '_all'} onValueChange={handleFilterChange(setDesignationFilter)}>
<SelectTrigger className="w-[180px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="All Designations" />
</SelectTrigger>
<SelectContent>
{designations.map((designation) => (
<SelectItem key={designation.id} value={designation.id}>
{designation.name}
</SelectItem>
<SelectItem value="_all">All Designations</SelectItem>
{designations.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={advocateFilter || '_all'} onValueChange={handleFilterChange(setAdvocateFilter)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Advocates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Advocates</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={teamMemberFilter || '_all'} onValueChange={handleFilterChange(setTeamMemberFilter)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Team Members" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Team Members</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setDesignationFilter('')
setAdvocateFilter('')
setTeamMemberFilter('')
setPage(1)
}}
>
<X className="h-4 w-4 mr-1" />
Clear filters
</Button>
)}
<div className="flex border rounded-md">
<Button
variant={viewMode === 'cards' ? 'default' : 'ghost'}

View file

@ -108,7 +108,7 @@ export function ClientTable({ clients }: ClientTableProps) {
<TableCell>
{nextPolicy ? (
<div className="flex items-center gap-2">
<span>{formatDate(nextPolicy.expirationDate)}</span>
<span suppressHydrationWarning>{formatDate(nextPolicy.expirationDate)}</span>
{daysToExpiration !== null && daysToExpiration <= 90 && (
<Badge
variant={daysToExpiration <= 30 ? 'destructive' : 'secondary'}

View file

@ -0,0 +1,573 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Plus,
Calendar,
FileText,
CheckSquare,
Pencil,
Trash2,
Zap,
ChevronDown,
ChevronUp,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { formatDate } from '@/lib/utils'
interface Policy {
id: string
policyNumber: string | null
policyType: string | null
expirationDate: string
department: string | null
carrierName: string | null
writingCompanyName: string | null
policyGroupId?: string | null
}
interface PolicyGroup {
id: string
name: string
renewalDate: string
notes: string | null
creator: { displayName: string | null; email: string } | null
policies: Omit<Policy, 'policyGroupId'>[]
_count: { tasks: number }
}
interface PolicyGroupManagerProps {
clientId: string
initialGroups: PolicyGroup[]
allPolicies: Policy[]
canManage: boolean
}
const emptyForm = {
name: 'Renewal Date',
renewalDate: '',
notes: '',
policyIds: [] as string[],
}
export function PolicyGroupManager({
clientId,
initialGroups,
allPolicies,
canManage,
}: PolicyGroupManagerProps) {
const [groups, setGroups] = useState<PolicyGroup[]>(initialGroups)
const [policies, setPolicies] = useState<Policy[]>(allPolicies)
const [formOpen, setFormOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<PolicyGroup | null>(null)
const [formData, setFormData] = useState(emptyForm)
const [submitting, setSubmitting] = useState(false)
const [expandedGroupId, setExpandedGroupId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<PolicyGroup | null>(null)
const [cancelTasks, setCancelTasks] = useState(false)
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
const assignedPolicyIds = new Set(
groups.flatMap((g) => g.policies.map((p) => p.id))
)
const getAvailablePolicies = (excludeGroupId?: string) => {
const groupPolicyIds = excludeGroupId
? new Set(groups.find((g) => g.id === excludeGroupId)?.policies.map((p) => p.id) ?? [])
: new Set<string>()
return policies.filter(
(p) => !assignedPolicyIds.has(p.id) || groupPolicyIds.has(p.id)
)
}
const handleOpenCreate = () => {
setEditingGroup(null)
setFormData(emptyForm)
setFormOpen(true)
}
const handleOpenEdit = (group: PolicyGroup) => {
setEditingGroup(group)
setFormData({
name: group.name,
renewalDate: group.renewalDate.split('T')[0],
notes: group.notes || '',
policyIds: group.policies.map((p) => p.id),
})
setFormOpen(true)
}
const togglePolicy = (policyId: string) => {
setFormData((prev) => ({
...prev,
policyIds: prev.policyIds.includes(policyId)
? prev.policyIds.filter((id) => id !== policyId)
: [...prev.policyIds, policyId],
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name || !formData.renewalDate) {
toast.error('Name and renewal date are required')
return
}
setSubmitting(true)
try {
if (editingGroup) {
const res = await fetch(`/api/policy-groups/${editingGroup.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
renewalDate: formData.renewalDate,
notes: formData.notes || null,
policyIds: formData.policyIds,
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Failed to update group')
}
const updated: PolicyGroup = await res.json()
setGroups((prev) => prev.map((g) => (g.id === updated.id ? updated : g)))
setPolicies((prev) =>
prev.map((p) => ({
...p,
policyGroupId: updated.policies.some((up) => up.id === p.id)
? updated.id
: p.policyGroupId === editingGroup.id
? null
: p.policyGroupId,
}))
)
toast.success('Group updated')
} else {
const res = await fetch(`/api/clients/${clientId}/policy-groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
renewalDate: formData.renewalDate,
notes: formData.notes || null,
policyIds: formData.policyIds,
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Failed to create group')
}
const created: PolicyGroup = await res.json()
setGroups((prev) => [...prev, created])
setPolicies((prev) =>
prev.map((p) => ({
...p,
policyGroupId: created.policies.some((cp) => cp.id === p.id)
? created.id
: p.policyGroupId,
}))
)
toast.success('Group created')
}
setFormOpen(false)
} catch (err: any) {
toast.error(err.message || 'An error occurred')
} finally {
setSubmitting(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
try {
const res = await fetch(
`/api/policy-groups/${deleteTarget.id}?cancelTasks=${cancelTasks}`,
{ method: 'DELETE' }
)
if (!res.ok) throw new Error('Failed to delete group')
setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id))
setPolicies((prev) =>
prev.map((p) => ({
...p,
policyGroupId:
deleteTarget.policies.some((dp) => dp.id === p.id)
? null
: p.policyGroupId,
}))
)
toast.success('Group deleted')
} catch {
toast.error('Failed to delete group')
} finally {
setDeleteTarget(null)
setCancelTasks(false)
}
}
const handleGenerateTasks = async (groupId: string) => {
setGeneratingFor(groupId)
try {
const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks`, {
method: 'POST',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to generate tasks')
setGroups((prev) =>
prev.map((g) =>
g.id === groupId
? { ...g, _count: { tasks: g._count.tasks + data.created } }
: g
)
)
if (data.created === 0) {
toast.info(data.message || 'No new tasks to generate')
} else {
toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`)
}
} catch (err: any) {
toast.error(err.message || 'Failed to generate tasks')
} finally {
setGeneratingFor(null)
}
}
const availableForForm = getAvailablePolicies(editingGroup?.id)
return (
<div className="space-y-4">
{canManage && (
<div className="flex justify-end">
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
New Renewal Group
</Button>
</div>
)}
{groups.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="font-medium">No renewal groups yet</p>
{canManage && (
<p className="text-sm mt-1">
Create a group to bundle policies under a shared renewal date and generate tasks.
</p>
)}
</CardContent>
</Card>
) : (
<div className="space-y-3">
{groups.map((group) => {
const isExpanded = expandedGroupId === group.id
return (
<Card key={group.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base">{group.name}</CardTitle>
<Badge variant="outline" className="text-xs">
<Calendar className="h-3 w-3 mr-1" />
<span suppressHydrationWarning>{formatDate(group.renewalDate)}</span>
</Badge>
<Badge variant="secondary" className="text-xs">
<FileText className="h-3 w-3 mr-1" />
{group.policies.length} polic{group.policies.length !== 1 ? 'ies' : 'y'}
</Badge>
<Badge variant="secondary" className="text-xs">
<CheckSquare className="h-3 w-3 mr-1" />
{group._count.tasks} task{group._count.tasks !== 1 ? 's' : ''}
</Badge>
</div>
{group.notes && (
<p className="text-sm text-muted-foreground mt-1 truncate">
{group.notes}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{canManage && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleGenerateTasks(group.id)}
disabled={generatingFor === group.id}
>
<Zap className="h-3.5 w-3.5 mr-1" />
{generatingFor === group.id ? 'Generating...' : 'Generate Tasks'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(group)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(group)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedGroupId(isExpanded ? null : group.id)
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0 border-t">
{group.policies.length === 0 ? (
<p className="text-sm text-muted-foreground py-3">
No policies assigned to this group.
</p>
) : (
<div className="space-y-2 pt-3">
{group.policies.map((policy) => (
<div
key={policy.id}
className="flex items-center justify-between text-sm p-2 rounded-md bg-muted/50"
>
<div>
<span className="font-medium">
{policy.policyType || 'Policy'}
</span>
{policy.policyNumber && (
<span className="text-muted-foreground ml-2">
#{policy.policyNumber}
</span>
)}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
{policy.writingCompanyName && (
<span>{policy.writingCompanyName}</span>
)}
<span suppressHydrationWarning>Exp: {formatDate(policy.expirationDate)}</span>
</div>
</div>
))}
</div>
)}
</CardContent>
)}
</Card>
)
})}
</div>
)}
{/* Create / Edit Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{editingGroup ? 'Edit Renewal Group' : 'Create Renewal Group'}
</DialogTitle>
<DialogDescription>
Group policies under a shared renewal date, then generate tasks from templates.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="group-name">Group Name *</Label>
<Input
id="group-name"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Renewal Date, Q1 Renewals"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="renewal-date">Renewal Date *</Label>
<Input
id="renewal-date"
type="date"
value={formData.renewalDate}
onChange={(e) =>
setFormData((prev) => ({ ...prev, renewalDate: e.target.value }))
}
required
/>
<p className="text-xs text-muted-foreground">
Task due dates are calculated relative to this date using template offsets.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="group-notes">Notes</Label>
<Textarea
id="group-notes"
value={formData.notes}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setFormData((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Optional notes about this renewal group"
rows={2}
/>
</div>
<div className="space-y-2">
<Label>Assign Policies</Label>
{availableForForm.length === 0 && formData.policyIds.length === 0 ? (
<p className="text-sm text-muted-foreground">
All policies are already assigned to groups.
</p>
) : (
<div className="border rounded-md divide-y max-h-48 overflow-y-auto">
{availableForForm.map((policy) => (
<label
key={policy.id}
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50"
>
<Checkbox
checked={formData.policyIds.includes(policy.id)}
onCheckedChange={() => togglePolicy(policy.id)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{policy.policyType || 'Policy'}
{policy.policyNumber && (
<span className="text-muted-foreground font-normal ml-2">
#{policy.policyNumber}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
<span suppressHydrationWarning>Exp: {formatDate(policy.expirationDate)}</span>
{policy.writingCompanyName && ` · ${policy.writingCompanyName}`}
</div>
</div>
</label>
))}
</div>
)}
{formData.policyIds.length > 0 && (
<p className="text-xs text-muted-foreground">
{formData.policyIds.length} polic{formData.policyIds.length !== 1 ? 'ies' : 'y'} selected
</p>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? 'Saving...'
: editingGroup
? 'Update Group'
: 'Create Group'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null)
setCancelTasks(false)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &quot;{deleteTarget?.name}&quot;?</AlertDialogTitle>
<AlertDialogDescription>
This will unlink {deleteTarget?.policies.length ?? 0} polic
{(deleteTarget?.policies.length ?? 0) !== 1 ? 'ies' : 'y'} from the group.
{(deleteTarget?._count.tasks ?? 0) > 0 && (
<span>
{' '}The group has {deleteTarget?._count.tasks} task
{(deleteTarget?._count.tasks ?? 0) !== 1 ? 's' : ''}.
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{(deleteTarget?._count.tasks ?? 0) > 0 && (
<div className="flex items-center gap-2 px-1">
<Checkbox
id="cancel-tasks"
checked={cancelTasks}
onCheckedChange={(v) => setCancelTasks(!!v)}
/>
<label htmlFor="cancel-tasks" className="text-sm cursor-pointer">
Also cancel open tasks from this group
</label>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Group
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -12,7 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Building2, Users, CheckSquare, Settings, LogOut, User, LayoutDashboard, Shapes } from 'lucide-react'
import { Building2, Users, CheckSquare, Settings, LogOut, User, LayoutDashboard, Shapes, UserPlus } from 'lucide-react'
import { ThemeToggle } from '@/components/theme-toggle'
export function NavBar() {
@ -30,6 +30,7 @@ export function NavBar() {
if (isManager) {
navItems.push({ href: '/manager', label: 'Manager', icon: Users })
navItems.push({ href: '/tasks/assign', label: 'Assign Tasks', icon: UserPlus })
}
if (isAdmin) {

View file

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,114 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { ChevronDown, X } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface ComboboxOption {
value: string
label: string
}
interface ComboboxProps {
options: ComboboxOption[]
value: string
onChange: (value: string) => void
placeholder?: string
emptyText?: string
className?: string
}
export function Combobox({
options,
value,
onChange,
placeholder = 'Select...',
emptyText = 'No results found',
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (option: ComboboxOption) => {
onChange(option.value)
setOpen(false)
setQuery('')
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
onChange('')
setQuery('')
}
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
className="flex items-center border rounded-md bg-background cursor-pointer"
onClick={() => { setOpen((o) => !o); setQuery('') }}
>
{open ? (
<Input
autoFocus
className="border-0 shadow-none focus-visible:ring-0 h-9"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={cn('flex-1 px-3 py-2 text-sm truncate', !selected && 'text-muted-foreground')}>
{selected ? selected.label : placeholder}
</span>
)}
<div className="flex items-center pr-2 gap-1">
{value && !open && (
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" onClick={handleClear} />
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-60 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
) : (
filtered.map((option) => (
<div
key={option.value}
className={cn(
'px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
option.value === value && 'bg-accent font-medium'
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(option)}
>
{option.label}
</div>
))
)}
</div>
)}
</div>
)
}

View file

@ -70,6 +70,16 @@ export const authOptions: NextAuthOptions = {
})
}
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'AUTH_SIGNIN',
entityType: 'User',
entityId: user.id,
newValues: { provider: 'credentials' },
},
}).catch(() => {})
return {
id: user.id,
email: user.email,
@ -143,6 +153,25 @@ export const authOptions: NextAuthOptions = {
})
}
// Log sign-in for Azure AD users
try {
const signedInUser = await prisma.user.findUnique({
where: { email: user.email! },
select: { id: true },
})
if (signedInUser) {
await prisma.auditLog.create({
data: {
userId: signedInUser.id,
action: 'AUTH_SIGNIN',
entityType: 'User',
entityId: signedInUser.id,
newValues: { provider: account?.provider || 'azure-ad' },
},
})
}
} catch {}
return true
},
async session({ session, token }) {