2026-04-08 11:19:37 +00:00
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
|
|
import { toast } from 'sonner'
|
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
|
|
|
import { formatDateTime } from '@/lib/utils'
|
2026-04-08 11:19:37 +00:00
|
|
|
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'
|
2026-04-08 13:34:54 +00:00
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select'
|
2026-04-08 11:19:37 +00:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
} from '@/components/ui/dialog'
|
|
|
|
|
import {
|
|
|
|
|
PlayCircle,
|
|
|
|
|
Loader2,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
XCircle,
|
|
|
|
|
Clock,
|
|
|
|
|
FileText,
|
2026-04-08 13:34:54 +00:00
|
|
|
FolderOpen,
|
2026-04-08 11:19:37 +00:00
|
|
|
AlertTriangle,
|
2026-04-08 13:34:54 +00:00
|
|
|
RefreshCw,
|
2026-04-13 19:40:34 +00:00
|
|
|
Zap,
|
2026-04-08 11:19:37 +00:00
|
|
|
} from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
|
2026-04-08 13:34:54 +00:00
|
|
|
const DEFAULT_FOLDER_PATH = 'Claims/SHAPE Accounts'
|
|
|
|
|
|
|
|
|
|
interface SpDrive { id: string; name: string; type: string }
|
|
|
|
|
interface SpFolder { id: string; name: string }
|
2026-04-08 11:19:37 +00:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 18:07:36 +00:00
|
|
|
function formatDate(iso: string) {
|
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
|
|
|
return formatDateTime(iso, { timeZoneName: undefined })
|
2026-04-08 18:07:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 11:19:37 +00:00
|
|
|
export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
|
2026-04-08 18:07:36 +00:00
|
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
|
useEffect(() => setMounted(true), [])
|
2026-04-08 11:19:37 +00:00
|
|
|
const [runs, setRuns] = useState<Run[]>(initialRuns)
|
2026-04-08 13:34:54 +00:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
2026-04-08 11:19:37 +00:00
|
|
|
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)
|
2026-04-13 19:40:34 +00:00
|
|
|
const [gapFillRunning, setGapFillRunning] = useState(false)
|
|
|
|
|
const [gapFillResult, setGapFillResult] = useState<Record<string, any> | null>(null)
|
2026-04-08 11:19:37 +00:00
|
|
|
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])
|
|
|
|
|
|
2026-04-08 13:43:02 +00:00
|
|
|
// Load document libraries on mount (site is fixed to Commercial)
|
2026-04-08 13:34:54 +00:00
|
|
|
useEffect(() => {
|
2026-04-08 13:43:02 +00:00
|
|
|
setLoadingDrives(true)
|
2026-04-08 13:34:54 +00:00
|
|
|
setPickerError(null)
|
2026-04-08 13:43:02 +00:00
|
|
|
fetch('/api/admin/shape-import/browse?level=drives')
|
2026-04-08 13:34:54 +00:00
|
|
|
.then((r) => r.json())
|
2026-04-08 13:43:02 +00:00
|
|
|
.then((d) => { if (d.drives) setDrives(d.drives); else setPickerError(d.error ?? 'Failed to load document libraries') })
|
2026-04-08 13:34:54 +00:00
|
|
|
.catch((e) => setPickerError(e.message))
|
2026-04-08 13:43:02 +00:00
|
|
|
.finally(() => setLoadingDrives(false))
|
2026-04-08 13:34:54 +00:00
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
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) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 11:19:37 +00:00
|
|
|
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' },
|
2026-04-08 13:34:54 +00:00
|
|
|
body: JSON.stringify({ dryRun, driveId: selectedDriveId, folderPath }),
|
2026-04-08 11:19:37 +00:00
|
|
|
})
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Failed to start')
|
2026-04-08 13:34:54 +00:00
|
|
|
const newRun: Run = { id: data.runId, startedAt: new Date().toISOString(), completedAt: null, status: 'running', dryRun, driveId: selectedDriveId, stats: null }
|
2026-04-08 11:19:37 +00:00
|
|
|
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
|
2026-04-08 13:34:54 +00:00
|
|
|
const canRun = !!selectedDriveId && !isRunning && !starting
|
2026-04-08 11:19:37 +00:00
|
|
|
|
|
|
|
|
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>
|
2026-04-08 13:34:54 +00:00
|
|
|
<CardContent className="space-y-5">
|
2026-04-08 11:19:37 +00:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-08 13:34:54 +00:00
|
|
|
{/* 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>
|
|
|
|
|
|
2026-04-08 13:43:02 +00:00
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
Site: <span className="font-medium text-foreground">seubert365.sharepoint.com/sites/Commercial</span>
|
2026-04-08 13:34:54 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Document library (drive) */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-xs">Document Library</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={selectedDriveId}
|
|
|
|
|
onValueChange={handleDriveChange}
|
2026-04-08 13:43:02 +00:00
|
|
|
disabled={isRunning || loadingDrives}
|
2026-04-08 13:34:54 +00:00
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
) : (
|
2026-04-08 13:43:02 +00:00
|
|
|
<SelectValue placeholder="Select a document library…" />
|
2026-04-08 13:34:54 +00:00
|
|
|
)}
|
|
|
|
|
</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={() => {
|
2026-04-08 13:43:02 +00:00
|
|
|
setSelectedDriveId(DEFAULT_DRIVE_ID)
|
2026-04-08 13:34:54 +00:00
|
|
|
setSelectedFolderId(''); setFolderPath(DEFAULT_FOLDER_PATH)
|
2026-04-08 13:43:02 +00:00
|
|
|
setFolders([])
|
2026-04-08 13:34:54 +00:00
|
|
|
}}
|
|
|
|
|
disabled={isRunning}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className="h-3 w-3" /> Reset to default
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-04-08 11:19:37 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => startRun(true)}
|
2026-04-08 13:34:54 +00:00
|
|
|
disabled={!canRun}
|
2026-04-08 11:19:37 +00:00
|
|
|
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)}
|
2026-04-08 13:34:54 +00:00
|
|
|
disabled={!canRun}
|
2026-04-08 11:19:37 +00:00
|
|
|
variant="destructive"
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
<AlertTriangle className="h-4 w-4" />
|
|
|
|
|
Execute Import
|
|
|
|
|
</Button>
|
2026-04-13 19:40:34 +00:00
|
|
|
|
|
|
|
|
<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>
|
2026-04-08 11:19:37 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-13 19:40:34 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-08 11:19:37 +00:00
|
|
|
{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">
|
2026-04-08 18:07:36 +00:00
|
|
|
{run.dryRun ? 'Dry Run' : 'Execute'} — {mounted ? formatDate(run.startedAt) : ''}
|
2026-04-08 11:19:37 +00:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|