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:
parent
6f2b8efef0
commit
0abcadaa71
31 changed files with 3053 additions and 102 deletions
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
235
ondeck/src/app/(dashboard)/admin/audit/page-client.tsx
Normal file
235
ondeck/src/app/(dashboard)/admin/audit/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
ondeck/src/app/(dashboard)/admin/audit/page.tsx
Normal file
26
ondeck/src/app/(dashboard)/admin/audit/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
288
ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx
Normal file
288
ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
ondeck/src/app/(dashboard)/tasks/assign/page.tsx
Normal file
40
ondeck/src/app/(dashboard)/tasks/assign/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
ondeck/src/app/api/admin/audit/route.ts
Normal file
73
ondeck/src/app/api/admin/audit/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
150
ondeck/src/app/api/clients/[id]/policy-groups/route.ts
Normal file
150
ondeck/src/app/api/clients/[id]/policy-groups/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
133
ondeck/src/app/api/clients/[id]/team/route.ts
Normal file
133
ondeck/src/app/api/clients/[id]/team/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
237
ondeck/src/app/api/cron/auto-generate/route.ts
Normal file
237
ondeck/src/app/api/cron/auto-generate/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
134
ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts
Normal file
134
ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
234
ondeck/src/app/api/policy-groups/[id]/route.ts
Normal file
234
ondeck/src/app/api/policy-groups/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
ondeck/src/app/api/tasks/bulk-assign/route.ts
Normal file
70
ondeck/src/app/api/tasks/bulk-assign/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
573
ondeck/src/components/clients/policy-group-manager.tsx
Normal file
573
ondeck/src/components/clients/policy-group-manager.tsx
Normal 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 "{deleteTarget?.name}"?</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
32
ondeck/src/components/ui/checkbox.tsx
Normal file
32
ondeck/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
114
ondeck/src/components/ui/combobox.tsx
Normal file
114
ondeck/src/components/ui/combobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue