diff --git a/ondeck/src/app/api/admin/gap-fill/route.ts b/ondeck/src/app/api/admin/gap-fill/route.ts new file mode 100644 index 0000000..a4352c2 --- /dev/null +++ b/ondeck/src/app/api/admin/gap-fill/route.ts @@ -0,0 +1,292 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/db' + +/** + * POST /api/admin/gap-fill + * Admin-only. Generates missing tasks for policy groups, ungrouped policies, + * and clients — without overwriting any existing tasks (skipDuplicates + existence checks). + */ +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')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const log: string[] = [] + let groupTasksCreated = 0 + let policyTasksCreated = 0 + let clientTasksCreated = 0 + const errors: string[] = [] + + // ── 1. Group-level tasks ──────────────────────────────────────────────── + const groups = await prisma.policyGroup.findMany({ + where: { policies: { some: {} } }, + 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, + level: { in: ['BOTH', 'RENEWAL_GROUP'] as any[] }, + 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) + + for (const template of templates) { + const dueDate = new Date(renewalDate) + dueDate.setDate(dueDate.getDate() + template.daysOffset) + + // Check if task already exists for this group+template (within 5 day window) + const windowMs = 5 * 86400 * 1000 + const existing = await prisma.task.findFirst({ + where: { + clientId: group.clientId, + templateId: template.id, + policyGroupId: group.id, + dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) }, + }, + select: { id: true }, + }) + if (existing) continue + + const created = await prisma.task.create({ + data: { + title: template.name, + description: template.description, + department: template.department, + timing: template.timing, + daysOffset: template.daysOffset, + dueDate, + status: 'NOT_STARTED', + priority: template.defaultPriority, + clientId: group.clientId, + policyGroupId: group.id, + templateId: template.id, + }, + select: { id: true }, + }) + groupTasksCreated++ + + if (group.client.claimsAdvocateId) { + await prisma.taskAssignment.create({ + data: { taskId: created.id, userId: group.client.claimsAdvocateId }, + }) + } + } + } catch (err: any) { + errors.push(`Group ${group.id}: ${err.message}`) + } + } + + log.push(`Group tasks created: ${groupTasksCreated}`) + + // ── 2. Policy-level tasks (ungrouped policies only) ───────────────────── + const ungroupedPolicies = await prisma.policy.findMany({ + where: { + policyGroupId: null, + expirationDate: { gte: new Date() }, + status: { notIn: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] as any[] }, + }, + select: { + id: true, + expirationDate: true, + clientId: true, + client: { select: { designationId: true, designation2Id: true, claimsAdvocateId: true } }, + }, + }) + + for (const policy of ungroupedPolicies) { + try { + const designationIds = [policy.client.designationId, policy.client.designation2Id].filter(Boolean) as string[] + + const templates = await prisma.taskTemplate.findMany({ + where: { + isActive: true, + level: { in: ['BOTH', 'POLICY'] as any[] }, + 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) + anchorDate.setDate(anchorDate.getDate() + 1) + + for (const template of templates) { + const dueDate = new Date(anchorDate) + dueDate.setDate(dueDate.getDate() + template.daysOffset) + + const windowMs = 5 * 86400 * 1000 + const existing = await prisma.task.findFirst({ + where: { + clientId: policy.clientId, + templateId: template.id, + policyId: policy.id, + dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) }, + }, + select: { id: true }, + }) + if (existing) continue + + const created = await prisma.task.create({ + data: { + title: template.name, + description: template.description, + department: template.department, + timing: template.timing, + daysOffset: template.daysOffset, + dueDate, + status: 'NOT_STARTED', + priority: template.defaultPriority, + clientId: policy.clientId, + policyId: policy.id, + templateId: template.id, + }, + select: { id: true }, + }) + policyTasksCreated++ + + if (policy.client.claimsAdvocateId) { + await prisma.taskAssignment.create({ + data: { taskId: created.id, userId: policy.client.claimsAdvocateId }, + }) + } + } + } catch (err: any) { + errors.push(`Policy ${policy.id}: ${err.message}`) + } + } + + log.push(`Policy tasks created: ${policyTasksCreated}`) + + // ── 3. Client-level tasks ─────────────────────────────────────────────── + const allClientIds = [...new Set([ + ...groups.map((g) => g.clientId), + ...ungroupedPolicies.map((p) => p.clientId), + ])] + + for (const clientId of allClientIds) { + try { + const clientRecord = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + designationId: true, + designation2Id: true, + claimsAdvocateId: true, + policyGroups: { select: { renewalDate: true } }, + policies: { where: { policyGroupId: null }, select: { expirationDate: true } }, + }, + }) + if (!clientRecord) continue + + const designationIds = [clientRecord.designationId, clientRecord.designation2Id].filter(Boolean) as string[] + + const clientTemplates = await prisma.taskTemplate.findMany({ + where: { + isActive: true, + level: 'CLIENT' as any, + OR: [ + { designationId: null }, + ...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []), + ], + }, + orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }], + }) + + if (clientTemplates.length === 0) continue + + const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime()) + const policyDates = clientRecord.policies.map((p) => { + const d = new Date(p.expirationDate) + d.setDate(d.getDate() + 1) + return d.getTime() + }) + const allDates = [...groupDates, ...policyDates] + if (allDates.length === 0) continue + const anchorDate = new Date(Math.min(...allDates)) + + for (const template of clientTemplates) { + const dueDate = new Date(anchorDate) + dueDate.setDate(dueDate.getDate() + template.daysOffset) + + const windowMs = 5 * 86400 * 1000 + const existing = await prisma.task.findFirst({ + where: { + clientId, + templateId: template.id, + policyId: null, + policyGroupId: null, + dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) }, + }, + select: { id: true }, + }) + if (existing) continue + + const created = await prisma.task.create({ + data: { + title: template.name, + description: template.description, + department: template.department, + timing: template.timing, + daysOffset: template.daysOffset, + dueDate, + status: 'NOT_STARTED', + priority: template.defaultPriority, + clientId, + templateId: template.id, + }, + select: { id: true }, + }) + clientTasksCreated++ + + if (clientRecord.claimsAdvocateId) { + await prisma.taskAssignment.create({ + data: { taskId: created.id, userId: clientRecord.claimsAdvocateId }, + }) + } + } + } catch (err: any) { + errors.push(`Client ${clientId}: ${err.message}`) + } + } + + log.push(`Client tasks created: ${clientTasksCreated}`) + + return NextResponse.json({ + success: true, + summary: { + groupTasksCreated, + policyTasksCreated, + clientTasksCreated, + totalCreated: groupTasksCreated + policyTasksCreated + clientTasksCreated, + errors: errors.length, + }, + log, + errors: errors.length > 0 ? errors.slice(0, 20) : undefined, + }) + } catch (error: any) { + console.error('Gap fill error:', error) + return NextResponse.json({ error: 'Internal server error', detail: error.message }, { status: 500 }) + } +} diff --git a/ondeck/src/components/admin/shape-import-panel.tsx b/ondeck/src/components/admin/shape-import-panel.tsx index 2f1a13a..8ea9366 100644 --- a/ondeck/src/components/admin/shape-import-panel.tsx +++ b/ondeck/src/components/admin/shape-import-panel.tsx @@ -30,6 +30,7 @@ import { FolderOpen, AlertTriangle, RefreshCw, + Zap, } from 'lucide-react' const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm' @@ -99,6 +100,8 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) { const [viewingRunId, setViewingRunId] = useState(null) const [confirmOpen, setConfirmOpen] = useState(false) const [starting, setStarting] = useState(false) + const [gapFillRunning, setGapFillRunning] = useState(false) + const [gapFillResult, setGapFillResult] = useState | null>(null) const logRef = useRef(null) const pollRef = useRef | null>(null) @@ -389,8 +392,40 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) { Execute Import + + + {gapFillResult && ( +
+

Gap Fill complete

+

Group tasks: {gapFillResult.groupTasksCreated} · Policy tasks: {gapFillResult.policyTasksCreated} · Client tasks: {gapFillResult.clientTasksCreated} · Total: {gapFillResult.totalCreated}

+ {gapFillResult.errors > 0 &&

{gapFillResult.errors} errors

} +
+ )} + {isRunning && (