From 0abcadaa71ffe704172209369d93eb056a4a95ce Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 18 Mar 2026 01:49:52 +0000 Subject: [PATCH] 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 --- .../migration.sql | 43 ++ .../migration.sql | 44 ++ .../migration.sql | 46 ++ ondeck/prisma/schema.prisma | 53 +- .../(dashboard)/admin/audit/page-client.tsx | 235 +++++++ .../src/app/(dashboard)/admin/audit/page.tsx | 26 + ondeck/src/app/(dashboard)/admin/page.tsx | 10 +- .../src/app/(dashboard)/clients/[id]/page.tsx | 89 ++- .../(dashboard)/tasks/assign/page-client.tsx | 288 +++++++++ .../src/app/(dashboard)/tasks/assign/page.tsx | 40 ++ ondeck/src/app/api/admin/audit/route.ts | 73 +++ .../api/clients/[id]/policy-groups/route.ts | 150 +++++ ondeck/src/app/api/clients/[id]/route.ts | 59 +- ondeck/src/app/api/clients/[id]/team/route.ts | 133 ++++ ondeck/src/app/api/clients/route.ts | 66 +- .../src/app/api/cron/auto-generate/route.ts | 237 ++++++++ .../[id]/generate-tasks/route.ts | 134 ++++ .../src/app/api/policy-groups/[id]/route.ts | 234 +++++++ ondeck/src/app/api/tasks/bulk-assign/route.ts | 70 +++ ondeck/src/app/api/tasks/route.ts | 11 + ondeck/src/app/api/users/[id]/route.ts | 54 +- ondeck/src/app/api/users/route.ts | 7 +- ondeck/src/components/clients/client-card.tsx | 2 +- .../src/components/clients/client-detail.tsx | 209 ++++++- ondeck/src/components/clients/client-list.tsx | 89 ++- .../src/components/clients/client-table.tsx | 2 +- .../clients/policy-group-manager.tsx | 573 ++++++++++++++++++ ondeck/src/components/layout/nav-bar.tsx | 3 +- ondeck/src/components/ui/checkbox.tsx | 32 + ondeck/src/components/ui/combobox.tsx | 114 ++++ ondeck/src/lib/auth.ts | 29 + 31 files changed, 3053 insertions(+), 102 deletions(-) create mode 100644 ondeck/prisma/migrations/20260218132407_add_policy_groups/migration.sql create mode 100644 ondeck/prisma/migrations/20260228173011_add_claims_advocate_and_team/migration.sql create mode 100644 ondeck/prisma/migrations/20260301002152_simplify_client_members/migration.sql create mode 100644 ondeck/src/app/(dashboard)/admin/audit/page-client.tsx create mode 100644 ondeck/src/app/(dashboard)/admin/audit/page.tsx create mode 100644 ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx create mode 100644 ondeck/src/app/(dashboard)/tasks/assign/page.tsx create mode 100644 ondeck/src/app/api/admin/audit/route.ts create mode 100644 ondeck/src/app/api/clients/[id]/policy-groups/route.ts create mode 100644 ondeck/src/app/api/clients/[id]/team/route.ts create mode 100644 ondeck/src/app/api/cron/auto-generate/route.ts create mode 100644 ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts create mode 100644 ondeck/src/app/api/policy-groups/[id]/route.ts create mode 100644 ondeck/src/app/api/tasks/bulk-assign/route.ts create mode 100644 ondeck/src/components/clients/policy-group-manager.tsx create mode 100644 ondeck/src/components/ui/checkbox.tsx create mode 100644 ondeck/src/components/ui/combobox.tsx diff --git a/ondeck/prisma/migrations/20260218132407_add_policy_groups/migration.sql b/ondeck/prisma/migrations/20260218132407_add_policy_groups/migration.sql new file mode 100644 index 0000000..82a79c6 --- /dev/null +++ b/ondeck/prisma/migrations/20260218132407_add_policy_groups/migration.sql @@ -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; diff --git a/ondeck/prisma/migrations/20260228173011_add_claims_advocate_and_team/migration.sql b/ondeck/prisma/migrations/20260228173011_add_claims_advocate_and_team/migration.sql new file mode 100644 index 0000000..35fb521 --- /dev/null +++ b/ondeck/prisma/migrations/20260228173011_add_claims_advocate_and_team/migration.sql @@ -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; diff --git a/ondeck/prisma/migrations/20260301002152_simplify_client_members/migration.sql b/ondeck/prisma/migrations/20260301002152_simplify_client_members/migration.sql new file mode 100644 index 0000000..03ca1e3 --- /dev/null +++ b/ondeck/prisma/migrations/20260301002152_simplify_client_members/migration.sql @@ -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; diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma index 6c72671..547d158 100644 --- a/ondeck/prisma/schema.prisma +++ b/ondeck/prisma/schema.prisma @@ -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]) diff --git a/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx b/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx new file mode 100644 index 0000000..d70a77c --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx @@ -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([]) + 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 ( +
+
+

+ + Audit Log +

+

+ {total} total entries +

+
+ + {/* Filters */} + + +
+ + + + + { setDateFrom(e.target.value); setPage(1) }} + placeholder="From date" + /> + { setDateTo(e.target.value); setPage(1) }} + placeholder="To date" + /> +
+
+
+ + {/* Table */} + + + {loading ? ( +
Loading...
+ ) : logs.length === 0 ? ( +
No audit log entries found
+ ) : ( + + + + Timestamp + Action + Performed By + Entity + Details + + + + {logs.map((log) => ( + + + {new Date(log.createdAt).toLocaleString()} + + + {log.action} + + + {log.user?.displayName || log.user?.email || System} + + + {log.entityType} + {log.entityId && ( + + {log.entityId.slice(0, 8)}… + + )} + + + {log.newValues && ( + {JSON.stringify(log.newValues)} + )} + + + ))} + +
+ )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ) +} diff --git a/ondeck/src/app/(dashboard)/admin/audit/page.tsx b/ondeck/src/app/(dashboard)/admin/audit/page.tsx new file mode 100644 index 0000000..85e2a19 --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/audit/page.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/ondeck/src/app/(dashboard)/admin/page.tsx b/ondeck/src/app/(dashboard)/admin/page.tsx index 616980b..875f111 100644 --- a/ondeck/src/app/(dashboard)/admin/page.tsx +++ b/ondeck/src/app/(dashboard)/admin/page.tsx @@ -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 ( diff --git a/ondeck/src/app/(dashboard)/clients/[id]/page.tsx b/ondeck/src/app/(dashboard)/clients/[id]/page.tsx index 6468733..ab61e99 100644 --- a/ondeck/src/app/(dashboard)/clients/[id]/page.tsx +++ b/ondeck/src/app/(dashboard)/clients/[id]/page.tsx @@ -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 (
- +
) } diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx new file mode 100644 index 0000000..e1e4612 --- /dev/null +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState>(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 = { + NOT_STARTED: 'secondary', + IN_PROGRESS: 'default', + COMPLETED: 'default', + BLOCKED: 'destructive', + } + + return ( +
+
+

+ + Bulk Task Assignment +

+

Filter tasks and assign them to a team member

+
+ + {/* Filter bar */} + + + + + Filters + + + +
+ + + + + + + +
+
+
+ + {/* Assignment action bar */} + {selected.size > 0 && ( + + +
+ + + {selected.size} task{selected.size !== 1 ? 's' : ''} selected + + + + +
+
+
+ )} + + {/* Tasks table */} + + + {loading ? ( +
Loading tasks...
+ ) : tasks.length === 0 ? ( +
No tasks match the current filters
+ ) : ( + + + + + 0} + onCheckedChange={toggleAll} + /> + + Task + Client + Department + Due Date + Status + Assigned To + + + + {tasks.map((task) => ( + toggleSelect(task.id)} + style={{ cursor: 'pointer' }} + > + e.stopPropagation()}> + toggleSelect(task.id)} + /> + + +
{task.title}
+ {task.description && ( +
{task.description}
+ )} +
+ {task.client?.name || '—'} + {task.department?.replace('_', ' ') || '—'} + + {formatDate(task.dueDate)} + + + + {task.status?.replace('_', ' ')} + + + + {task.assignments?.length > 0 + ? task.assignments.map((a: any) => a.user?.displayName || a.user?.email).join(', ') + : Unassigned + } + +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page.tsx new file mode 100644 index 0000000..105e88c --- /dev/null +++ b/ondeck/src/app/(dashboard)/tasks/assign/page.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/ondeck/src/app/api/admin/audit/route.ts b/ondeck/src/app/api/admin/audit/route.ts new file mode 100644 index 0000000..ca98686 --- /dev/null +++ b/ondeck/src/app/api/admin/audit/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/clients/[id]/policy-groups/route.ts b/ondeck/src/app/api/clients/[id]/policy-groups/route.ts new file mode 100644 index 0000000..c480d8e --- /dev/null +++ b/ondeck/src/app/api/clients/[id]/policy-groups/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/clients/[id]/route.ts b/ondeck/src/app/api/clients/[id]/route.ts index 170c966..475c1b9 100644 --- a/ondeck/src/app/api/clients/[id]/route.ts +++ b/ondeck/src/app/api/clients/[id]/route.ts @@ -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[] = [ + 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) { diff --git a/ondeck/src/app/api/clients/[id]/team/route.ts b/ondeck/src/app/api/clients/[id]/team/route.ts new file mode 100644 index 0000000..501e4a3 --- /dev/null +++ b/ondeck/src/app/api/clients/[id]/team/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/clients/route.ts b/ondeck/src/app/api/clients/route.ts index d5e8394..b9f8961 100644 --- a/ondeck/src/app/api/clients/route.ts +++ b/ondeck/src/app/api/clients/route.ts @@ -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 } diff --git a/ondeck/src/app/api/cron/auto-generate/route.ts b/ondeck/src/app/api/cron/auto-generate/route.ts new file mode 100644 index 0000000..744727e --- /dev/null +++ b/ondeck/src/app/api/cron/auto-generate/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts b/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts new file mode 100644 index 0000000..6b09c13 --- /dev/null +++ b/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/policy-groups/[id]/route.ts b/ondeck/src/app/api/policy-groups/[id]/route.ts new file mode 100644 index 0000000..74c318b --- /dev/null +++ b/ondeck/src/app/api/policy-groups/[id]/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/tasks/bulk-assign/route.ts b/ondeck/src/app/api/tasks/bulk-assign/route.ts new file mode 100644 index 0000000..b87a8aa --- /dev/null +++ b/ondeck/src/app/api/tasks/bulk-assign/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/tasks/route.ts b/ondeck/src/app/api/tasks/route.ts index 9899c33..f1dd1f8 100644 --- a/ondeck/src/app/api/tasks/route.ts +++ b/ondeck/src/app/api/tasks/route.ts @@ -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: { diff --git a/ondeck/src/app/api/users/[id]/route.ts b/ondeck/src/app/api/users/[id]/route.ts index af37223..8b05621 100644 --- a/ondeck/src/app/api/users/[id]/route.ts +++ b/ondeck/src/app/api/users/[id]/route.ts @@ -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[] = [ + 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 }, diff --git a/ondeck/src/app/api/users/route.ts b/ondeck/src/app/api/users/route.ts index cfc988a..012cd85 100644 --- a/ondeck/src/app/api/users/route.ts +++ b/ondeck/src/app/api/users/route.ts @@ -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 }, diff --git a/ondeck/src/components/clients/client-card.tsx b/ondeck/src/components/clients/client-card.tsx index 7aa36df..bb2f1e0 100644 --- a/ondeck/src/components/clients/client-card.tsx +++ b/ondeck/src/components/clients/client-card.tsx @@ -86,7 +86,7 @@ export function ClientCard({ client }: ClientCardProps) {
Next expiration: - + {formatDate(nextPolicy.expirationDate)} {daysToExpiration !== null && daysToExpiration <= 90 && ( diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index 3c7fbaa..d5e497c 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -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([]) + const [claimsUsers, setClaimsUsers] = useState([]) + const [advocateId, setAdvocateId] = useState(client.claimsAdvocate?.id || '') + const [advocateSaving, setAdvocateSaving] = useState(false) + const [members, setMembers] = useState(client.members || []) + const [addMemberId, setAddMemberId] = useState('') + + 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) {
+ {/* Claims Assignments */} + + + + + Claims Assignment + + + + {/* Advocate */} +
+ +
+ ({ value: u.id, label: u.displayName || u.email }))} + value={advocateId} + onChange={setAdvocateId} + placeholder="Select advocate (Claims dept)" + emptyText="No Claims staff found" + /> + +
+
+ + {/* Team Members */} +
+ +
+ {members.length === 0 && ( +

No members assigned

+ )} + {members.map((m: any) => ( +
+
+ + {m.user?.displayName || m.user?.email} +
+ +
+ ))} +
+ !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..." + /> + +
+
+
+
+
+ {/* Designation Assignment */} @@ -131,6 +281,10 @@ export function ClientDetail({ client, designations }: ClientDetailProps) { Tasks ({client.tasks.length}) + + + Renewal Groups ({policyGroups.length}) + @@ -230,24 +384,41 @@ export function ClientDetail({ client, designations }: ClientDetailProps) { - {client.tasks.map((task: any) => ( - - -
-
-

{task.title}

-

{task.description}

-
-
- {task.status} -

- Due: {formatDate(task.dueDate)} -

-
-
+ {client.tasks.length === 0 ? ( + + + No tasks found - ))} + ) : ( + client.tasks.map((task: any) => ( + + +
+
+

{task.title}

+

{task.description}

+
+
+ {task.status} +

+ Due: {formatDate(task.dueDate)} +

+
+
+
+
+ )) + )} +
+ + + diff --git a/ondeck/src/components/clients/client-list.tsx b/ondeck/src/components/clients/client-list.tsx index 1fed721..9a5758c 100644 --- a/ondeck/src/components/clients/client-list.tsx +++ b/ondeck/src/components/clients/client-list.tsx @@ -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('') + const [advocateFilter, setAdvocateFilter] = useState('') + const [teamMemberFilter, setTeamMemberFilter] = useState('') const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [viewMode, setViewMode] = useState('cards') + const [users, setUsers] = useState([]) 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 (
{/* Filters */} -
-
+
+
- + - {designations.map((designation) => ( - - {designation.name} - + All Designations + {designations.map((d) => ( + {d.name} ))} + + + {hasActiveFilters && ( + + )}
+
+ )} + + {groups.length === 0 ? ( + + + +

No renewal groups yet

+ {canManage && ( +

+ Create a group to bundle policies under a shared renewal date and generate tasks. +

+ )} +
+
+ ) : ( +
+ {groups.map((group) => { + const isExpanded = expandedGroupId === group.id + return ( + + +
+
+
+ {group.name} + + + {formatDate(group.renewalDate)} + + + + {group.policies.length} polic{group.policies.length !== 1 ? 'ies' : 'y'} + + + + {group._count.tasks} task{group._count.tasks !== 1 ? 's' : ''} + +
+ {group.notes && ( +

+ {group.notes} +

+ )} +
+
+ {canManage && ( + <> + + + + + )} + +
+
+
+ + {isExpanded && ( + + {group.policies.length === 0 ? ( +

+ No policies assigned to this group. +

+ ) : ( +
+ {group.policies.map((policy) => ( +
+
+ + {policy.policyType || 'Policy'} + + {policy.policyNumber && ( + + #{policy.policyNumber} + + )} +
+
+ {policy.writingCompanyName && ( + {policy.writingCompanyName} + )} + Exp: {formatDate(policy.expirationDate)} +
+
+ ))} +
+ )} +
+ )} +
+ ) + })} +
+ )} + + {/* Create / Edit Dialog */} + + +
+ + + {editingGroup ? 'Edit Renewal Group' : 'Create Renewal Group'} + + + Group policies under a shared renewal date, then generate tasks from templates. + + + +
+
+ + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="e.g., Renewal Date, Q1 Renewals" + required + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, renewalDate: e.target.value })) + } + required + /> +

+ Task due dates are calculated relative to this date using template offsets. +

+
+ +
+ +