seubert-claims/ondeck/src/components/admin/shape-import-panel.tsx
lorentz 6dd7e3e836 fix(tasks): timezone consistency, task provenance, setup N/A, and task-generation fixes
Timezone (all users EST):
- lib/utils: hardcode APP_TIME_ZONE (America/New_York) in formatDate/
  formatRenewalDate, add formatDateTime and todayInAppTimeZone helpers
- Replace every ad-hoc toLocaleDateString/toLocaleString call across the app
  (task notes, audit log, backups, shape import, renewal groups, client
  detail) with the shared EST-aware helpers
- Fix UTC "today" bug in date-input defaults/min/max (completion date,
  reminder date) that rolled to the next calendar day after ~7-8pm ET

Task provenance:
- Task card info icon (now visible to all users, not just privileged) shows
  full origin: template, level, renewal anchor, due-date math, and who/what
  generated the task
- Include template.daysOffset and creator in task queries

Setup N/A:
- New setupNaAt/setupNaReason fields on Client; mark/restore UI and API to
  exclude non-Shape/lost-business clients from the setup queue everywhere
  it's counted (queue page, API, manager dashboard, metrics gauge)

Task generation fixes:
- auto-generate: client-level branch now gated on the renewal anchor's
  (group/policy) createdAt instead of the client's, so pre-go-live clients
  with new post-go-live groups are no longer skipped forever
- New auto-assign-tasks sweep run after every sync to assign advocates to
  tasks created by paths that don't assign directly (e.g. setup wizard)
2026-07-16 22:32:04 +00:00

589 lines
23 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { toast } from 'sonner'
import { formatDateTime } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
PlayCircle,
Loader2,
CheckCircle2,
XCircle,
Clock,
FileText,
FolderOpen,
AlertTriangle,
RefreshCw,
Zap,
} from 'lucide-react'
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
const DEFAULT_FOLDER_PATH = 'Claims/SHAPE Accounts'
interface SpDrive { id: string; name: string; type: string }
interface SpFolder { id: string; name: string }
interface Run {
id: string
startedAt: string
completedAt: string | null
status: string
dryRun: boolean
driveId: string
stats: Record<string, any> | null
user?: { displayName: string | null; email: string } | null
}
interface LogLine {
seq: number
line: string
}
function StatusBadge({ status }: { status: string }) {
if (status === 'completed') return <Badge className="bg-green-600">Completed</Badge>
if (status === 'failed') return <Badge variant="destructive">Failed</Badge>
if (status === 'running') return <Badge className="bg-blue-600 animate-pulse">Running</Badge>
return <Badge variant="secondary">Pending</Badge>
}
function StatLine({ label, value }: { label: string; value: number | string }) {
return (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function formatDate(iso: string) {
return formatDateTime(iso, { timeZoneName: undefined })
}
export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const [runs, setRuns] = useState<Run[]>(initialRuns)
// SharePoint picker state
const [drives, setDrives] = useState<SpDrive[]>([])
const [folders, setFolders] = useState<SpFolder[]>([])
const [selectedDriveId, setSelectedDriveId] = useState<string>(DEFAULT_DRIVE_ID)
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [folderPath, setFolderPath] = useState<string>(DEFAULT_FOLDER_PATH)
const [loadingDrives, setLoadingDrives] = useState(false)
const [loadingFolders, setLoadingFolders] = useState(false)
const [pickerError, setPickerError] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [activeRunStatus, setActiveRunStatus] = useState<string>('idle')
const [logLines, setLogLines] = useState<LogLine[]>([])
const [lastSeq, setLastSeq] = useState(-1)
const [viewingRunId, setViewingRunId] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [starting, setStarting] = useState(false)
const [gapFillRunning, setGapFillRunning] = useState(false)
const [gapFillResult, setGapFillResult] = useState<Record<string, any> | null>(null)
const logRef = useRef<HTMLPreElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const scrollToBottom = useCallback(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [])
useEffect(() => {
scrollToBottom()
}, [logLines, scrollToBottom])
const stopPolling = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
}, [])
const pollRun = useCallback(async (runId: string, currentSeq: number) => {
try {
const res = await fetch(`/api/admin/shape-import/${runId}?after=${currentSeq}`)
if (!res.ok) return
const data = await res.json()
if (data.logs && data.logs.length > 0) {
setLogLines((prev) => {
const existing = new Set(prev.map((l) => l.seq))
const newLines = (data.logs as LogLine[]).filter((l) => !existing.has(l.seq))
return [...prev, ...newLines]
})
const maxSeq = Math.max(...(data.logs as LogLine[]).map((l) => l.seq))
setLastSeq(maxSeq)
currentSeq = maxSeq
}
if (data.run?.status === 'completed' || data.run?.status === 'failed') {
setActiveRunStatus(data.run.status)
stopPolling()
setRuns((prev) =>
prev.map((r) => (r.id === runId ? { ...r, status: data.run.status, completedAt: data.run.completedAt, stats: data.run.stats } : r))
)
}
} catch {
// transient — keep polling
}
}, [stopPolling])
const startPolling = useCallback((runId: string) => {
stopPolling()
let seq = -1
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/admin/shape-import/${runId}?after=${seq}`)
if (!res.ok) return
const data = await res.json()
if (data.logs && data.logs.length > 0) {
setLogLines((prev) => {
const existing = new Set(prev.map((l) => l.seq))
const newLines = (data.logs as LogLine[]).filter((l) => !existing.has(l.seq))
return [...prev, ...newLines]
})
seq = Math.max(...(data.logs as LogLine[]).map((l) => l.seq))
setLastSeq(seq)
}
if (data.run?.status === 'completed' || data.run?.status === 'failed') {
setActiveRunStatus(data.run.status)
stopPolling()
setRuns((prev) =>
prev.map((r) => (r.id === runId ? { ...r, status: data.run.status, completedAt: data.run.completedAt, stats: data.run.stats } : r))
)
if (data.run.status === 'completed') toast.success('Import completed')
else toast.error('Import failed — check the log for details')
}
} catch { /* keep polling */ }
}, 2000)
}, [stopPolling])
useEffect(() => () => stopPolling(), [stopPolling])
// Load document libraries on mount (site is fixed to Commercial)
useEffect(() => {
setLoadingDrives(true)
setPickerError(null)
fetch('/api/admin/shape-import/browse?level=drives')
.then((r) => r.json())
.then((d) => { if (d.drives) setDrives(d.drives); else setPickerError(d.error ?? 'Failed to load document libraries') })
.catch((e) => setPickerError(e.message))
.finally(() => setLoadingDrives(false))
}, [])
const handleDriveChange = async (driveId: string) => {
setSelectedDriveId(driveId)
setSelectedFolderId('')
setFolderPath(DEFAULT_FOLDER_PATH)
setFolders([])
setLoadingFolders(true)
setPickerError(null)
try {
const res = await fetch(`/api/admin/shape-import/browse?level=folders&driveId=${encodeURIComponent(driveId)}`)
const d = await res.json()
if (d.folders) setFolders(d.folders)
else setPickerError(d.error ?? 'Failed to load folders')
} catch (e: any) { setPickerError(e.message) }
finally { setLoadingFolders(false) }
}
const handleFolderChange = async (folderId: string) => {
setSelectedFolderId(folderId)
const folderName = folders.find((f) => f.id === folderId)?.name ?? ''
// Build path: drill one level into selected folder
const newPath = folderName ? folderName : DEFAULT_FOLDER_PATH
setFolderPath(newPath)
// Load subfolders so user can go deeper if needed
setLoadingFolders(true)
try {
const res = await fetch(`/api/admin/shape-import/browse?level=folders&driveId=${encodeURIComponent(selectedDriveId)}&itemId=${encodeURIComponent(folderId)}`)
const d = await res.json()
if (d.folders && d.folders.length > 0) setFolders(d.folders)
} catch { /* ignore */ }
finally { setLoadingFolders(false) }
}
const startRun = async (dryRun: boolean) => {
setStarting(true)
setLogLines([])
setLastSeq(-1)
try {
const res = await fetch('/api/admin/shape-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dryRun, driveId: selectedDriveId, folderPath }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to start')
const newRun: Run = { id: data.runId, startedAt: new Date().toISOString(), completedAt: null, status: 'running', dryRun, driveId: selectedDriveId, stats: null }
setRuns((prev) => [newRun, ...prev])
setActiveRunId(data.runId)
setViewingRunId(data.runId)
setActiveRunStatus('running')
startPolling(data.runId)
toast.info(dryRun ? 'Dry run started — results will appear in the log' : 'Import started')
} catch (err: any) {
toast.error(err.message || 'Failed to start import')
} finally {
setStarting(false)
}
}
const loadRunLog = async (runId: string) => {
setViewingRunId(runId)
setLogLines([])
setLastSeq(-1)
try {
const res = await fetch(`/api/admin/shape-import/${runId}?after=-1`)
if (!res.ok) return
const data = await res.json()
setLogLines(data.logs || [])
if (data.logs?.length) setLastSeq(data.logs[data.logs.length - 1].seq)
} catch { /* ignore */ }
}
const isRunning = activeRunStatus === 'running' && activeRunId !== null
const canRun = !!selectedDriveId && !isRunning && !starting
return (
<div className="space-y-6">
{/* Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
SHAPE Historical Import
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<p className="text-sm text-muted-foreground">
Import historical task completion data from SHAPE Excel files on SharePoint into Horizon.
Always run a <strong>dry run first</strong> it reads SharePoint and shows what would be
created/updated without writing anything to the database.
</p>
{/* SharePoint location picker */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-sm font-medium flex items-center gap-1.5">
<FolderOpen className="h-4 w-4" /> SharePoint Location
</p>
{pickerError && (
<p className="text-xs text-destructive">{pickerError}</p>
)}
</div>
<div className="text-xs text-muted-foreground">
Site: <span className="font-medium text-foreground">seubert365.sharepoint.com/sites/Commercial</span>
</div>
{/* Document library (drive) */}
<div className="space-y-1">
<Label className="text-xs">Document Library</Label>
<Select
value={selectedDriveId}
onValueChange={handleDriveChange}
disabled={isRunning || loadingDrives}
>
<SelectTrigger className="h-8 text-sm">
{loadingDrives ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading libraries
</span>
) : (
<SelectValue placeholder="Select a document library…" />
)}
</SelectTrigger>
<SelectContent>
{drives.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Folder */}
{folders.length > 0 && (
<div className="space-y-1">
<Label className="text-xs">Folder</Label>
<Select
value={selectedFolderId}
onValueChange={handleFolderChange}
disabled={isRunning || loadingFolders}
>
<SelectTrigger className="h-8 text-sm">
{loadingFolders ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading folders
</span>
) : (
<SelectValue placeholder="Select a folder…" />
)}
</SelectTrigger>
<SelectContent>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id}>{f.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Summary line */}
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-muted-foreground">
Path: <code className="bg-background px-1 py-0.5 rounded">{folderPath}</code>
</p>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs gap-1"
onClick={() => {
setSelectedDriveId(DEFAULT_DRIVE_ID)
setSelectedFolderId(''); setFolderPath(DEFAULT_FOLDER_PATH)
setFolders([])
}}
disabled={isRunning}
>
<RefreshCw className="h-3 w-3" /> Reset to default
</Button>
</div>
</div>
<div className="flex gap-3">
<Button
onClick={() => startRun(true)}
disabled={!canRun}
variant="outline"
className="gap-2"
>
{isRunning && viewingRunId === activeRunId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PlayCircle className="h-4 w-4" />
)}
Run Dry Run
</Button>
<Button
onClick={() => setConfirmOpen(true)}
disabled={!canRun}
variant="destructive"
className="gap-2"
>
<AlertTriangle className="h-4 w-4" />
Execute Import
</Button>
<Button
onClick={async () => {
setGapFillRunning(true)
setGapFillResult(null)
try {
const res = await fetch('/api/admin/gap-fill', { method: 'POST' })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Gap fill failed')
setGapFillResult(data.summary)
toast.success(`Gap Fill: ${data.summary.totalCreated} tasks created`)
} catch (err: any) {
toast.error(err.message)
} finally {
setGapFillRunning(false)
}
}}
disabled={gapFillRunning || isRunning}
variant="outline"
className="gap-2"
>
{gapFillRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
Gap Fill
</Button>
</div>
{gapFillResult && (
<div className="text-sm p-3 rounded-lg bg-muted/40 space-y-1">
<p className="font-medium">Gap Fill complete</p>
<p className="text-muted-foreground">Group tasks: {gapFillResult.groupTasksCreated} · Policy tasks: {gapFillResult.policyTasksCreated} · Client tasks: {gapFillResult.clientTasksCreated} · Total: {gapFillResult.totalCreated}</p>
{gapFillResult.errors > 0 && <p className="text-destructive">{gapFillResult.errors} errors</p>}
</div>
)}
{isRunning && (
<div className="flex items-center gap-2 text-sm text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />
Import running log updating live below
</div>
)}
</CardContent>
</Card>
{/* Log viewer */}
{viewingRunId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Import Log
{isRunning && viewingRunId === activeRunId && (
<Badge className="bg-blue-600 animate-pulse text-xs">Live</Badge>
)}
</span>
{(() => {
const run = runs.find((r) => r.id === viewingRunId)
return run ? <StatusBadge status={run.status} /> : null
})()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats summary (completed runs) */}
{(() => {
const run = runs.find((r) => r.id === viewingRunId)
if (!run?.stats || run.status === 'running') return null
const s = run.stats
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 bg-muted/40 rounded-lg">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Clients</p>
<StatLine label="Matched" value={s.clientsMatched ?? 0} />
<StatLine label="Unmatched" value={s.clientsUnmatched ?? 0} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Tasks</p>
<StatLine label="Created" value={s.tasksCreated ?? 0} />
<StatLine label="Updated" value={s.tasksUpdated ?? 0} />
<StatLine label="Assigned" value={s.tasksAssigned ?? 0} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Other</p>
<StatLine label="Ad-hoc" value={s.adHocCreated ?? 0} />
<StatLine label="Duplicates deleted" value={s.duplicatesDeleted ?? 0} />
<StatLine label="Files" value={s.filesProcessed ?? 0} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Issues</p>
<StatLine label="Errors" value={(s.errors as any[])?.length ?? 0} />
<StatLine label="Fuzzy matches" value={(s.fuzzyMatches as any[])?.length ?? 0} />
</div>
</div>
)
})()}
{/* Log */}
<pre
ref={logRef}
className="bg-zinc-950 text-zinc-100 text-xs font-mono rounded-lg p-4 overflow-auto max-h-[500px] whitespace-pre-wrap leading-relaxed"
>
{logLines.length === 0
? '(waiting for output...)'
: logLines.map((l) => l.line).join('\n')}
</pre>
</CardContent>
</Card>
)}
{/* Run history */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Run History
</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No runs yet</p>
) : (
<div className="space-y-2">
{runs.map((run) => (
<button
key={run.id}
onClick={() => loadRunLog(run.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors hover:bg-accent ${viewingRunId === run.id ? 'bg-accent border-primary' : ''}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{run.status === 'completed' ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : run.status === 'failed' ? (
<XCircle className="h-4 w-4 text-red-600 shrink-0" />
) : (
<Loader2 className="h-4 w-4 text-blue-600 animate-spin shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.dryRun ? 'Dry Run' : 'Execute'} {mounted ? formatDate(run.startedAt) : ''}
</p>
{run.user && (
<p className="text-xs text-muted-foreground">by {run.user.displayName || run.user.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={run.status} />
{run.stats && (
<span className="text-xs text-muted-foreground tabular-nums">
{(run.stats as any).tasksCreated ?? 0} created · {(run.stats as any).errors?.length ?? 0} errors
</span>
)}
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Execute confirmation dialog */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Execute SHAPE Import
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>This will <strong>write data to the database</strong>:</p>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Create or update tasks for matched SHAPE clients</li>
<li>Mark historical tasks as Completed or N/A</li>
<li>Assign claims advocates to clients (if not already set)</li>
<li>Delete duplicate tasks</li>
</ul>
<p className="text-muted-foreground">
Run a <strong>dry run first</strong> and review the log if you haven't already.
This action cannot be automatically undone.
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => { setConfirmOpen(false); startRun(false) }}
>
Yes, Execute Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}